@mojaloop/central-services-shared 18.30.7 → 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,13 @@
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
+
5
12
  ### [18.30.7](https://github.com/mojaloop/central-services-shared/compare/v18.30.6...v18.30.7) (2025-08-08)
6
13
 
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojaloop/central-services-shared",
3
- "version": "18.30.7",
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",
@@ -162,6 +162,59 @@ const sendRequest = async ({
162
162
  status: error.response?.status,
163
163
  data: error.response?.data
164
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
165
218
  const extensionArray = [
166
219
  { key: 'url', value: url },
167
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