@mojaloop/central-services-shared 18.30.7-snapshot.2 → 18.30.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [18.30.8](https://github.com/mojaloop/central-services-shared/compare/v18.30.7...v18.30.8) (2025-09-01)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * issue 4198 503 error handling ([#474](https://github.com/mojaloop/central-services-shared/issues/474)) ([8974f81](https://github.com/mojaloop/central-services-shared/commit/8974f81a5a3ee478d8014cff82f3329f51b9c654))
11
+
12
+ ### [18.30.7](https://github.com/mojaloop/central-services-shared/compare/v18.30.6...v18.30.7) (2025-08-08)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * http keep alive new object ([#473](https://github.com/mojaloop/central-services-shared/issues/473)) ([ef4b287](https://github.com/mojaloop/central-services-shared/commit/ef4b2871d6f0c39319d55efa8e512de5d9483cff))
18
+
5
19
  ### [18.30.6](https://github.com/mojaloop/central-services-shared/compare/v18.30.5...v18.30.6) (2025-07-23)
6
20
 
7
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojaloop/central-services-shared",
3
- "version": "18.30.7-snapshot.2",
3
+ "version": "18.30.8",
4
4
  "description": "Shared code for mojaloop central services",
5
5
  "license": "Apache-2.0",
6
6
  "author": "ModusBox",
@@ -65,7 +65,7 @@
65
65
  "dependencies": {
66
66
  "@hapi/catbox": "12.1.1",
67
67
  "@hapi/catbox-memory": "5.0.1",
68
- "@hapi/hapi": "21.4.2",
68
+ "@hapi/hapi": "21.4.3",
69
69
  "@hapi/joi-date": "2.0.1",
70
70
  "@mojaloop/inter-scheme-proxy-cache-lib": "2.6.0",
71
71
  "@opentelemetry/api": "1.9.0",
@@ -80,10 +80,10 @@
80
80
  "fast-safe-stringify": "2.1.1",
81
81
  "immutable": "5.1.3",
82
82
  "ioredis": "5.7.0",
83
- "joi": "18.0.0",
83
+ "joi": "18.0.1",
84
84
  "lodash": "4.17.21",
85
85
  "mustache": "4.2.0",
86
- "openapi-backend": "5.13.0",
86
+ "openapi-backend": "5.15.0",
87
87
  "raw-body": "3.0.0",
88
88
  "rc": "1.2.8",
89
89
  "redlock": "5.0.0-beta.2",
@@ -95,11 +95,11 @@
95
95
  },
96
96
  "devDependencies": {
97
97
  "@mojaloop/central-services-error-handling": "13.1.0",
98
- "@mojaloop/central-services-logger": "11.9.0",
98
+ "@mojaloop/central-services-logger": "11.9.1",
99
99
  "@mojaloop/central-services-metrics": "12.6.0",
100
100
  "@mojaloop/event-sdk": "14.6.1",
101
- "@mojaloop/sdk-standard-components": "19.16.4",
102
- "@opentelemetry/auto-instrumentations-node": "^0.62.0",
101
+ "@mojaloop/sdk-standard-components": "19.16.7",
102
+ "@opentelemetry/auto-instrumentations-node": "^0.62.1",
103
103
  "@types/hapi__joi": "17.1.15",
104
104
  "ajv": "^8.17.1",
105
105
  "ajv-formats": "^3.0.1",
@@ -107,13 +107,13 @@
107
107
  "audit-ci": "7.1.0",
108
108
  "base64url": "3.0.1",
109
109
  "chance": "1.1.13",
110
- "npm-check-updates": "18.0.2",
110
+ "npm-check-updates": "18.0.3",
111
111
  "nyc": "17.1.0",
112
112
  "portfinder": "1.0.37",
113
113
  "pre-commit": "1.2.2",
114
114
  "proxyquire": "2.1.3",
115
115
  "replace": "1.2.2",
116
- "rewire": "9.0.0",
116
+ "rewire": "9.0.1",
117
117
  "sinon": "21.0.0",
118
118
  "standard": "17.1.2",
119
119
  "standard-version": "9.5.0",
@@ -28,8 +28,10 @@
28
28
 
29
29
  'use strict'
30
30
 
31
- const { logger } = require('../logger')
32
- const { statusEnum } = require('./HealthCheckEnums')
31
+ const {
32
+ statusEnum
33
+ } = require('./HealthCheckEnums')
34
+ const Logger = require('@mojaloop/central-services-logger')
33
35
 
34
36
  /**
35
37
  * @class HealthCheck
@@ -80,7 +82,6 @@ class HealthCheck {
80
82
  constructor (packageJson, serviceChecks) {
81
83
  this.packageJson = packageJson
82
84
  this.serviceChecks = serviceChecks
83
- this.log = logger.child({ component: this.constructor.name })
84
85
 
85
86
  this.getHealth = this.getHealth.bind(this)
86
87
  }
@@ -109,7 +110,7 @@ class HealthCheck {
109
110
  services
110
111
  }
111
112
  } catch (err) {
112
- this.log.warn(`HealthCheck.getHealth failed with error: ${err?.message}`)
113
+ Logger.isErrorEnabled && Logger.error(`HealthCheck.getSubServiceHealth failed with error: ${err.message}`)
113
114
  isHealthy = false
114
115
  }
115
116
 
package/src/index.d.ts CHANGED
@@ -778,7 +778,7 @@ declare namespace CentralServicesShared {
778
778
  RedisCache: RedisCache;
779
779
  }
780
780
 
781
- type RedisInstanceConfig =
781
+ type RedisInstanceConfig =
782
782
  | {
783
783
  type: 'redis';
784
784
  host: string;
@@ -830,69 +830,7 @@ declare namespace CentralServicesShared {
830
830
 
831
831
  const Enum: Enum
832
832
  const Util: Util
833
-
834
- namespace HealthCheck {
835
- enum StatusEnum {
836
- OK = 'OK',
837
- DOWN = 'DOWN'
838
- }
839
-
840
- enum ServiceNameEnum {
841
- participantEndpointService = 'participantEndpointService',
842
- smtpServer = 'smtpServer',
843
- datastore = 'datastore',
844
- broker = 'broker',
845
- sidecar = 'sidecar',
846
- cache = 'cache',
847
- proxyCache = 'proxyCache'
848
- }
849
-
850
- enum ResponseCodeEnum {
851
- success = 200,
852
- gatewayTimeout = 502
853
- }
854
-
855
- interface SubServiceHealth {
856
- status: StatusEnum;
857
- service: ServiceNameEnum;
858
- }
859
-
860
- type ServiceCheckerFunc = (context?: any) => Promise<SubServiceHealth>;
861
-
862
- interface HealthCheckResult {
863
- status: StatusEnum;
864
- uptime: number;
865
- startTime: string;
866
- versionNumber: string;
867
- services?: SubServiceHealth[];
868
- }
869
-
870
- export const HealthCheckEnums: {
871
- responseCode: {
872
- success: ResponseCodeEnum.success;
873
- gatewayTimeout: ResponseCodeEnum.gatewayTimeout;
874
- };
875
- serviceName: {
876
- participantEndpointService: ServiceNameEnum.participantEndpointService;
877
- smtpServer: ServiceNameEnum.smtpServer;
878
- datastore: ServiceNameEnum.datastore;
879
- broker: ServiceNameEnum.broker;
880
- sidecar: ServiceNameEnum.sidecar;
881
- cache: ServiceNameEnum.cache;
882
- proxyCache: ServiceNameEnum.proxyCache;
883
- };
884
- statusEnum: {
885
- OK: StatusEnum.OK;
886
- DOWN: StatusEnum.DOWN;
887
- };
888
- }
889
-
890
- export class HealthCheck {
891
- constructor(packageJson: { version: string }, serviceChecks: ServiceCheckerFunc[]);
892
- getHealth(context?: any): Promise<HealthCheckResult>;
893
- static evaluateServiceHealth(services: SubServiceHealth[]): boolean;
894
- }
895
- }
833
+ const HealthCheck: any
896
834
 
897
835
  namespace mysql {
898
836
  class KnexWrapper {
@@ -125,10 +125,8 @@ const sendRequest = async ({
125
125
  data: payload, // todo: think, if it's better to transform to ISO format here (based on apiType)
126
126
  params,
127
127
  responseType,
128
- httpAgent: new http.Agent({ keepAlive: true }),
129
128
  ...axiosRequestOptionsOverride
130
129
  }
131
- requestOptions.httpAgent.toJSON = () => ({})
132
130
  // if jwsSigner is passed then sign the request
133
131
  if (jwsSigner != null && typeof (jwsSigner) === 'object') {
134
132
  requestOptions.headers['fspiop-signature'] = jwsSigner.getSignature(requestOptions)
@@ -164,6 +162,59 @@ const sendRequest = async ({
164
162
  status: error.response?.status,
165
163
  data: error.response?.data
166
164
  })
165
+
166
+ // Check if this is an HTTP response error (4xx or 5xx) vs a network/connection error
167
+ if (error.response) {
168
+ // For HTTP errors, check if the response contains FSPIOP error information
169
+ const responseData = error.response.data
170
+
171
+ // If the response contains a valid errorInformation object, use it directly
172
+ if (responseData?.errorInformation) {
173
+ const fspiopError = ErrorHandler.Factory.createFSPIOPErrorFromErrorInformation(responseData.errorInformation)
174
+ if (sendRequestSpan) {
175
+ const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message)
176
+ await sendRequestSpan.error(fspiopError, state)
177
+ await sendRequestSpan.finish(fspiopError.message, state)
178
+ }
179
+ histTimerEnd({ success: false, source, destination, method })
180
+ throw fspiopError
181
+ }
182
+
183
+ // For other 4xx errors without errorInformation, create appropriate FSPIOP error
184
+ if (error.response.status >= 400 && error.response.status < 500) {
185
+ let errorCode = ErrorHandler.Enums.FSPIOPErrorCodes.CLIENT_ERROR
186
+ let errorMessage = 'Client error'
187
+
188
+ // Map specific HTTP status codes to FSPIOP error codes
189
+ if (error.response.status === 400) {
190
+ errorCode = ErrorHandler.Enums.FSPIOPErrorCodes.CLIENT_ERROR
191
+ errorMessage = responseData?.message || 'Bad Request'
192
+ } else if (error.response.status === 404) {
193
+ errorCode = ErrorHandler.Enums.FSPIOPErrorCodes.ID_NOT_FOUND
194
+ errorMessage = responseData?.message || 'The requested resource could not be found'
195
+ } else if (error.response.status === 403) {
196
+ errorCode = ErrorHandler.Enums.FSPIOPErrorCodes.CLIENT_ERROR
197
+ errorMessage = responseData?.message || 'Permission denied'
198
+ }
199
+
200
+ const extensions = [
201
+ { key: 'url', value: url },
202
+ { key: 'status', value: error.response.status },
203
+ { key: 'response', value: stringify(responseData) }
204
+ ]
205
+
206
+ const fspiopError = ErrorHandler.Factory.createFSPIOPError(errorCode, errorMessage, error, source, extensions)
207
+ if (sendRequestSpan) {
208
+ const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message)
209
+ await sendRequestSpan.error(fspiopError, state)
210
+ await sendRequestSpan.finish(fspiopError.message, state)
211
+ }
212
+ histTimerEnd({ success: false, source, destination, method })
213
+ throw fspiopError
214
+ }
215
+ }
216
+
217
+ // For network errors or 5xx errors, use DESTINATION_COMMUNICATION_ERROR
167
218
  const extensionArray = [
168
219
  { key: 'url', value: url },
169
220
  { key: 'sourceFsp', value: source },
@@ -73,6 +73,111 @@ Test('ParticipantEndpoint Model Test', modelTest => {
73
73
  })
74
74
 
75
75
  modelTest.test('sendRequest should', async (getEndpointTest) => {
76
+ // Factory for creating spans with common setup
77
+ const createSpan = (overrides = {}) => {
78
+ const spanFinishStub = sandbox.stub()
79
+ const spanAuditStub = sandbox.stub()
80
+ const spanErrorStub = sandbox.stub()
81
+ const sendRequestSpan = {
82
+ setTags: sandbox.stub(),
83
+ finish: spanFinishStub,
84
+ error: spanErrorStub,
85
+ ...overrides.sendRequestSpan
86
+ }
87
+ return {
88
+ span: {
89
+ getChild: sandbox.stub().returns(sendRequestSpan),
90
+ getContext: sandbox.stub().returns({ service: 'test' }),
91
+ injectContextToHttpRequest: sandbox.stub(requestOptions => requestOptions),
92
+ audit: spanAuditStub,
93
+ ...overrides.span
94
+ },
95
+ stubs: { spanFinishStub, spanAuditStub, spanErrorStub, sendRequestSpan }
96
+ }
97
+ }
98
+
99
+ // Factory for creating request options
100
+ const createRequestOptions = (fsp = 'fsp1', method = 'get') => ({
101
+ url: Mustache.render(Config.ENDPOINT_SOURCE_URL + Enum.EndPoints.FspEndpointTemplates.PARTICIPANT_ENDPOINTS_GET, { fsp }),
102
+ method
103
+ })
104
+
105
+ // Generic sendRequest caller
106
+ const callSendRequest = async (options = {}) => {
107
+ const { url, headers, source = hubName, destination = hubName, ...rest } = options
108
+ return Model.sendRequest({
109
+ url: url || createRequestOptions().url,
110
+ headers: headers || Helper.defaultHeaders(hubName, Enum.Http.HeaderResources.PARTICIPANTS, hubName),
111
+ source,
112
+ destination,
113
+ hubNameRegex,
114
+ ...rest
115
+ })
116
+ }
117
+
118
+ // Helper function to test error handling scenarios
119
+ const testErrorHandling = async (test, errorConfig, expectations) => {
120
+ const { fsp = 'fsp1', errorMessage, errorCode, responseData } = errorConfig
121
+ const customError = new Error(errorMessage)
122
+ customError.code = errorCode
123
+ if (responseData) customError.response = responseData
124
+
125
+ request = sandbox.stub().throws(customError)
126
+ Model = proxyquire('../../../src/util/request', { axios: request })
127
+
128
+ try {
129
+ await callSendRequest({ url: createRequestOptions(fsp).url })
130
+ test.fail('should throw error')
131
+ } catch (e) {
132
+ test.ok(e instanceof Error, 'Error was thrown')
133
+ Object.entries(expectations).forEach(([key, value]) => {
134
+ if (key === 'notEqual') {
135
+ test.notEqual(e.apiErrorCode.code, value.code, value.message)
136
+ } else if (key === 'apiErrorCode') {
137
+ test.equal(e.apiErrorCode.code, value, expectations.apiErrorCodeMessage || 'Error code matches expected')
138
+ } else if (key === 'message') {
139
+ test.equal(e.message, value, expectations.messageDescription || 'Error message matches expected')
140
+ }
141
+ })
142
+ }
143
+ test.end()
144
+ }
145
+
146
+ // Helper function to test span string payload handling
147
+ const testSpanStringPayload = async (test, payloadStr, expectedPayload, description) => {
148
+ const { span, stubs } = createSpan()
149
+ request = sandbox.stub().returns(Helper.getEndPointsResponse)
150
+ Model = proxyquire('../../../src/util/request', { axios: request })
151
+
152
+ await callSendRequest({ method: 'post', payload: payloadStr, span })
153
+
154
+ test.ok(stubs.spanAuditStub.calledOnce, 'Span audit is called')
155
+ const auditCallArgs = stubs.spanAuditStub.getCall(0).args[0]
156
+ const assertion = typeof expectedPayload === 'object' ? 'deepEqual' : 'equal'
157
+ test[assertion](auditCallArgs.payload, expectedPayload, description)
158
+ test.end()
159
+ }
160
+
161
+ // Helper function to test error handling with span
162
+ const testErrorWithSpan = async (test, errorConfig, expectedCode) => {
163
+ const { span, stubs } = createSpan()
164
+ request = sandbox.stub().throws(errorConfig)
165
+ Model = proxyquire('../../../src/util/request', { axios: request })
166
+
167
+ try {
168
+ await callSendRequest({ span })
169
+ test.fail('should throw error')
170
+ } catch (e) {
171
+ test.ok(stubs.spanErrorStub.called, 'Span error is called')
172
+ test.ok(stubs.spanFinishStub.called, 'Span finish is called')
173
+ if (stubs.spanErrorStub.callCount > 0) {
174
+ const errorArg = stubs.spanErrorStub.getCall(0).args[0]
175
+ test.equal(errorArg.apiErrorCode.code, expectedCode, `Error code ${expectedCode} passed to span`)
176
+ }
177
+ }
178
+ test.end()
179
+ }
180
+
76
181
  getEndpointTest.test('return the object of endpoints', async (test) => {
77
182
  const fsp = 'fsp'
78
183
  const requestOptions = {
@@ -293,6 +398,80 @@ Test('ParticipantEndpoint Model Test', modelTest => {
293
398
  }
294
399
  })
295
400
 
401
+ getEndpointTest.test('preserve 400 error with errorInformation from downstream service', async (test) => {
402
+ await testErrorHandling(test, {
403
+ fsp: 'nonexistentfsp',
404
+ errorMessage: 'Request failed with status code 400',
405
+ errorCode: 'ERR_BAD_REQUEST',
406
+ responseData: {
407
+ status: 400,
408
+ data: {
409
+ errorInformation: {
410
+ errorCode: '3200',
411
+ errorDescription: 'FSP not found'
412
+ }
413
+ }
414
+ }
415
+ }, {
416
+ apiErrorCode: '3200',
417
+ apiErrorCodeMessage: 'Error code is preserved from errorInformation',
418
+ message: 'FSP not found',
419
+ messageDescription: 'Error message is preserved from errorInformation',
420
+ notEqual: { code: '1001', message: 'Error is not converted to DESTINATION_COMMUNICATION_ERROR' }
421
+ })
422
+ })
423
+
424
+ getEndpointTest.test('handle 404 error without errorInformation', async (test) => {
425
+ await testErrorHandling(test, {
426
+ fsp: 'notfound',
427
+ errorMessage: 'Request failed with status code 404',
428
+ errorCode: 'ERR_BAD_REQUEST',
429
+ responseData: {
430
+ status: 404,
431
+ data: {
432
+ message: 'Resource not found'
433
+ }
434
+ }
435
+ }, {
436
+ apiErrorCode: '3200',
437
+ apiErrorCodeMessage: 'Error code is ID_NOT_FOUND for 404',
438
+ message: 'Resource not found',
439
+ messageDescription: 'Error message is preserved',
440
+ notEqual: { code: '1001', message: 'Error is not converted to DESTINATION_COMMUNICATION_ERROR' }
441
+ })
442
+ })
443
+
444
+ getEndpointTest.test('handle network error as DESTINATION_COMMUNICATION_ERROR', async (test) => {
445
+ await testErrorHandling(test, {
446
+ errorMessage: 'ECONNREFUSED',
447
+ errorCode: 'ECONNREFUSED'
448
+ // No response property for network errors
449
+ }, {
450
+ apiErrorCode: '1001',
451
+ apiErrorCodeMessage: 'Network error is converted to DESTINATION_COMMUNICATION_ERROR',
452
+ message: 'Failed to send HTTP request to host',
453
+ messageDescription: 'Generic error message for network errors'
454
+ })
455
+ })
456
+
457
+ getEndpointTest.test('handle 500 error as DESTINATION_COMMUNICATION_ERROR', async (test) => {
458
+ await testErrorHandling(test, {
459
+ errorMessage: 'Request failed with status code 500',
460
+ errorCode: 'ERR_BAD_RESPONSE',
461
+ responseData: {
462
+ status: 500,
463
+ data: {
464
+ message: 'Internal Server Error'
465
+ }
466
+ }
467
+ }, {
468
+ apiErrorCode: '1001',
469
+ apiErrorCodeMessage: '5xx error is converted to DESTINATION_COMMUNICATION_ERROR',
470
+ message: 'Failed to send HTTP request to host',
471
+ messageDescription: 'Generic error message for server errors'
472
+ })
473
+ })
474
+
296
475
  getEndpointTest.test('sign with JWS signature when JwsSigner object is passed', async (test) => {
297
476
  const fsp = 'payerfsp'
298
477
  const payeefsp = 'payeefsp'
@@ -359,6 +538,71 @@ Test('ParticipantEndpoint Model Test', modelTest => {
359
538
  test.end()
360
539
  })
361
540
 
541
+ getEndpointTest.test('handle span with string payload that can be parsed as JSON', async (test) => {
542
+ await testSpanStringPayload(test, '{"test": "data"}', { test: 'data' }, 'String payload is parsed to JSON for audit')
543
+ })
544
+
545
+ getEndpointTest.test('handle span with string payload that cannot be parsed as JSON', async (test) => {
546
+ await testSpanStringPayload(test, 'not valid json', 'not valid json', 'String payload remains as string when not parseable')
547
+ })
548
+
549
+ getEndpointTest.test('handle 403 error without errorInformation', async (test) => {
550
+ await testErrorHandling(test, {
551
+ fsp: 'forbidden',
552
+ errorMessage: 'Request failed with status code 403',
553
+ responseData: {
554
+ status: 403,
555
+ data: { message: 'You do not have permission' }
556
+ }
557
+ }, {
558
+ apiErrorCode: '3000',
559
+ message: 'You do not have permission'
560
+ })
561
+ })
562
+
563
+ getEndpointTest.test('handle 400 error without errorInformation and without message', async (test) => {
564
+ await testErrorHandling(test, {
565
+ fsp: 'badrequest',
566
+ errorMessage: 'Request failed with status code 400',
567
+ responseData: {
568
+ status: 400,
569
+ data: {}
570
+ }
571
+ }, {
572
+ apiErrorCode: '3000',
573
+ message: 'Bad Request'
574
+ })
575
+ })
576
+
577
+ getEndpointTest.test('handle error with errorInformation and span', async (test) => {
578
+ const customError = new Error('Request failed with status code 400')
579
+ customError.response = {
580
+ status: 400,
581
+ data: {
582
+ errorInformation: {
583
+ errorCode: '3200',
584
+ errorDescription: 'FSP not found'
585
+ }
586
+ }
587
+ }
588
+ await testErrorWithSpan(test, customError, '3200')
589
+ })
590
+
591
+ getEndpointTest.test('handle 4xx error without errorInformation and with span', async (test) => {
592
+ const customError = new Error('Request failed with status code 403')
593
+ customError.response = {
594
+ status: 403,
595
+ data: { message: 'Forbidden' }
596
+ }
597
+ await testErrorWithSpan(test, customError, '3000')
598
+ })
599
+
600
+ getEndpointTest.test('handle network error with span', async (test) => {
601
+ const customError = new Error('ECONNREFUSED')
602
+ customError.code = 'ECONNREFUSED'
603
+ await testErrorWithSpan(test, customError, '1001')
604
+ })
605
+
362
606
  getEndpointTest.end()
363
607
  })
364
608