@defra/lis-infra-ui-services 0.36.0 → 0.37.0
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/README.md +2 -0
- package/package.json +1 -1
- package/src/errors/index.js +47 -12
- package/src/errors/index.test.js +94 -19
- package/src/redis-client.js +1 -1
- package/src/redis-client.test.js +4 -2
- package/src/status-codes.js +1 -0
package/README.md
CHANGED
|
@@ -180,6 +180,8 @@ The main entry point (`@defra/lis-infra-ui-services`) exports `buildMicrositePat
|
|
|
180
180
|
| 503 | `service-unavailable` |
|
|
181
181
|
| anything else | `problem-with-service` |
|
|
182
182
|
|
|
183
|
+
5xx responses are logged at `error`, and 4xx responses other than 401, 403 and 404 at `warn`, each with the error object, request method, path and status code. Boom messages are logged, so application code must not put user-supplied text in them.
|
|
184
|
+
|
|
183
185
|
```js
|
|
184
186
|
import { catchAll } from '@defra/lis-infra-ui-services/errors'
|
|
185
187
|
|
package/package.json
CHANGED
package/src/errors/index.js
CHANGED
|
@@ -28,16 +28,56 @@ function getStatusCode(response) {
|
|
|
28
28
|
return response?.statusCode ?? response?.output?.statusCode
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
const routineClientErrors = new Set([
|
|
32
|
+
statusCodes.notFound,
|
|
33
|
+
statusCodes.unauthorized,
|
|
34
|
+
statusCodes.forbidden
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
function getError(response, statusCode) {
|
|
38
|
+
const error = response?.source instanceof Error ? response.source : response
|
|
39
|
+
if (error instanceof Error) {
|
|
40
|
+
return error
|
|
41
|
+
}
|
|
42
|
+
if (typeof response?.source?.message === 'string') {
|
|
43
|
+
return new Error(response.source.message)
|
|
44
|
+
}
|
|
45
|
+
return new Error(`Request failed with status ${statusCode}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isLoggableClientError(response, statusCode) {
|
|
32
49
|
return (
|
|
33
|
-
response?.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
`Request failed with status ${statusCode}`
|
|
50
|
+
response?.isBoom === true &&
|
|
51
|
+
statusCode >= statusCodes.badRequest &&
|
|
52
|
+
statusCode < statusCodes.internalServerError &&
|
|
53
|
+
!routineClientErrors.has(statusCode)
|
|
38
54
|
)
|
|
39
55
|
}
|
|
40
56
|
|
|
57
|
+
function logFailure(request, statusCode) {
|
|
58
|
+
const { response } = request
|
|
59
|
+
const isServerError =
|
|
60
|
+
statusCode >= statusCodes.internalServerError &&
|
|
61
|
+
!isAnsweredByMaintenance(request)
|
|
62
|
+
|
|
63
|
+
if (!isServerError && !isLoggableClientError(response, statusCode)) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Keys mirror hapi-pino's shape because the CDP pipeline maps them to ECS fields and drops the rest; request.path deliberately omits the query string
|
|
68
|
+
const context = {
|
|
69
|
+
err: getError(response, statusCode),
|
|
70
|
+
req: { method: request.method, url: request.path },
|
|
71
|
+
res: { statusCode }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (isServerError) {
|
|
75
|
+
logger.error(context, 'Request failed')
|
|
76
|
+
} else {
|
|
77
|
+
logger.warn(context, 'Request rejected')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
41
81
|
/**
|
|
42
82
|
* @param {Request} request
|
|
43
83
|
* @param {ResponseToolkit} h
|
|
@@ -47,12 +87,7 @@ export function catchAll(request, h) {
|
|
|
47
87
|
const { response } = request
|
|
48
88
|
const statusCode = getStatusCode(response)
|
|
49
89
|
|
|
50
|
-
|
|
51
|
-
statusCode >= statusCodes.internalServerError &&
|
|
52
|
-
!isAnsweredByMaintenance(request)
|
|
53
|
-
) {
|
|
54
|
-
logger.error(getLogMessage(response, statusCode))
|
|
55
|
-
}
|
|
90
|
+
logFailure(request, statusCode)
|
|
56
91
|
|
|
57
92
|
if (!('isBoom' in response)) {
|
|
58
93
|
return h.continue
|
package/src/errors/index.test.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import { afterEach, test, vi } from 'vitest'
|
|
2
|
+
import { afterEach, expect, test, vi } from 'vitest'
|
|
3
3
|
|
|
4
4
|
import { statusCodes } from '../status-codes.js'
|
|
5
5
|
|
|
6
6
|
const { logger } = vi.hoisted(() => ({
|
|
7
|
-
logger: { error: vi.fn() }
|
|
7
|
+
logger: { error: vi.fn(), warn: vi.fn() }
|
|
8
8
|
}))
|
|
9
9
|
|
|
10
10
|
vi.mock('@defra/lis-hubs-infra-core', () => ({ logger }))
|
|
@@ -23,13 +23,11 @@ function createToolkit() {
|
|
|
23
23
|
return toolkit
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function createBoomRequest(statusCode) {
|
|
26
|
+
function createBoomRequest(statusCode, error = new Error('Mock error')) {
|
|
27
27
|
return {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
output: { statusCode }
|
|
32
|
-
}
|
|
28
|
+
method: 'get',
|
|
29
|
+
path: '/mock-path',
|
|
30
|
+
response: Object.assign(error, { isBoom: true, output: { statusCode } })
|
|
33
31
|
}
|
|
34
32
|
}
|
|
35
33
|
|
|
@@ -106,19 +104,54 @@ test('catchAll logs server errors and renders the problem with the service page'
|
|
|
106
104
|
assert.deepEqual(toolkit.code.mock.calls[0], [
|
|
107
105
|
statusCodes.internalServerError
|
|
108
106
|
])
|
|
109
|
-
assert.deepEqual(logger.error.mock.calls[0], [
|
|
107
|
+
assert.deepEqual(logger.error.mock.calls[0], [
|
|
108
|
+
{
|
|
109
|
+
err: request.response,
|
|
110
|
+
req: { method: 'get', url: '/mock-path' },
|
|
111
|
+
res: { statusCode: statusCodes.internalServerError }
|
|
112
|
+
},
|
|
113
|
+
'Request failed'
|
|
114
|
+
])
|
|
115
|
+
assert.equal(logger.warn.mock.calls.length, 0)
|
|
110
116
|
})
|
|
111
117
|
|
|
112
|
-
test(
|
|
118
|
+
test.each([statusCodes.badRequest, statusCodes.unprocessableEntity])(
|
|
119
|
+
'catchAll warns about a %i with the error',
|
|
120
|
+
(statusCode) => {
|
|
121
|
+
// Arrange
|
|
122
|
+
const request = createBoomRequest(statusCode, new Error('Invalid input'))
|
|
123
|
+
const toolkit = createToolkit()
|
|
124
|
+
|
|
125
|
+
// Act
|
|
126
|
+
catchAll(request, toolkit)
|
|
127
|
+
|
|
128
|
+
// Assert
|
|
129
|
+
assert.deepEqual(logger.warn.mock.calls[0], [
|
|
130
|
+
{
|
|
131
|
+
err: request.response,
|
|
132
|
+
req: { method: 'get', url: '/mock-path' },
|
|
133
|
+
res: { statusCode }
|
|
134
|
+
},
|
|
135
|
+
'Request rejected'
|
|
136
|
+
])
|
|
137
|
+
assert.equal(logger.error.mock.calls.length, 0)
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
test.each([
|
|
142
|
+
statusCodes.notFound,
|
|
143
|
+
statusCodes.unauthorized,
|
|
144
|
+
statusCodes.forbidden
|
|
145
|
+
])('catchAll does not log a routine %i', (statusCode) => {
|
|
113
146
|
// Arrange
|
|
114
|
-
const request = createBoomRequest(
|
|
115
|
-
const toolkit = createToolkit()
|
|
147
|
+
const request = createBoomRequest(statusCode)
|
|
116
148
|
|
|
117
149
|
// Act
|
|
118
|
-
catchAll(request,
|
|
150
|
+
catchAll(request, createToolkit())
|
|
119
151
|
|
|
120
152
|
// Assert
|
|
121
153
|
assert.equal(logger.error.mock.calls.length, 0)
|
|
154
|
+
assert.equal(logger.warn.mock.calls.length, 0)
|
|
122
155
|
})
|
|
123
156
|
|
|
124
157
|
test('catchAll prefers a client-attached statusCode over a boomified 500', () => {
|
|
@@ -127,7 +160,6 @@ test('catchAll prefers a client-attached statusCode over a boomified 500', () =>
|
|
|
127
160
|
response: {
|
|
128
161
|
isBoom: true,
|
|
129
162
|
statusCode: statusCodes.notFound,
|
|
130
|
-
stack: 'Mock error stack',
|
|
131
163
|
output: { statusCode: statusCodes.internalServerError }
|
|
132
164
|
}
|
|
133
165
|
}
|
|
@@ -161,15 +193,16 @@ test('catchAll does not log the 503 the maintenance plugin answered with', () =>
|
|
|
161
193
|
assert.equal(logger.error.mock.calls.length, 0)
|
|
162
194
|
})
|
|
163
195
|
|
|
164
|
-
test('catchAll logs plain 500 responses before continuing', () => {
|
|
196
|
+
test('catchAll logs the error behind plain 500 responses before continuing', () => {
|
|
165
197
|
// Arrange
|
|
166
198
|
const toolkit = createToolkit()
|
|
199
|
+
const sourceError = new Error('Plain 500 response')
|
|
167
200
|
const request = {
|
|
201
|
+
method: 'post',
|
|
202
|
+
path: '/plain',
|
|
168
203
|
response: {
|
|
169
204
|
statusCode: statusCodes.internalServerError,
|
|
170
|
-
source:
|
|
171
|
-
message: 'Plain 500 response'
|
|
172
|
-
}
|
|
205
|
+
source: sourceError
|
|
173
206
|
}
|
|
174
207
|
}
|
|
175
208
|
|
|
@@ -178,6 +211,48 @@ test('catchAll logs plain 500 responses before continuing', () => {
|
|
|
178
211
|
|
|
179
212
|
// Assert
|
|
180
213
|
assert.equal(result, toolkit.continue)
|
|
181
|
-
assert.deepEqual(logger.error.mock.calls[0], [
|
|
214
|
+
assert.deepEqual(logger.error.mock.calls[0], [
|
|
215
|
+
{
|
|
216
|
+
err: sourceError,
|
|
217
|
+
req: { method: 'post', url: '/plain' },
|
|
218
|
+
res: { statusCode: statusCodes.internalServerError }
|
|
219
|
+
},
|
|
220
|
+
'Request failed'
|
|
221
|
+
])
|
|
182
222
|
assert.equal(toolkit.view.mock.calls.length, 0)
|
|
183
223
|
})
|
|
224
|
+
|
|
225
|
+
test('catchAll falls back to a generated error when none is attached', () => {
|
|
226
|
+
// Arrange
|
|
227
|
+
const request = {
|
|
228
|
+
method: 'get',
|
|
229
|
+
path: '/x',
|
|
230
|
+
response: { statusCode: statusCodes.internalServerError, source: {} }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Act
|
|
234
|
+
catchAll(request, createToolkit())
|
|
235
|
+
|
|
236
|
+
// Assert
|
|
237
|
+
expect(logger.error.mock.calls[0][0].err.message).toBe(
|
|
238
|
+
'Request failed with status 500'
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('catchAll logs the message of a plain 500 response whose source has one', () => {
|
|
243
|
+
// Arrange
|
|
244
|
+
const request = {
|
|
245
|
+
method: 'get',
|
|
246
|
+
path: '/x',
|
|
247
|
+
response: {
|
|
248
|
+
statusCode: statusCodes.internalServerError,
|
|
249
|
+
source: { message: 'Something broke' }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Act
|
|
254
|
+
catchAll(request, createToolkit())
|
|
255
|
+
|
|
256
|
+
// Assert
|
|
257
|
+
expect(logger.error.mock.calls[0][0].err.message).toBe('Something broke')
|
|
258
|
+
})
|
package/src/redis-client.js
CHANGED
package/src/redis-client.test.js
CHANGED
|
@@ -130,11 +130,13 @@ test('buildRedisClient uses the default port and logs client events', () => {
|
|
|
130
130
|
})
|
|
131
131
|
|
|
132
132
|
handlers.connect()
|
|
133
|
-
|
|
133
|
+
const connectionError = new Error('connection lost')
|
|
134
|
+
handlers.error(connectionError)
|
|
134
135
|
|
|
135
136
|
assert.deepEqual(logger.info.mock.calls[0], ['Connected to Redis server'])
|
|
136
137
|
assert.deepEqual(logger.error.mock.calls[0], [
|
|
137
|
-
|
|
138
|
+
{ err: connectionError },
|
|
139
|
+
'Redis connection error'
|
|
138
140
|
])
|
|
139
141
|
})
|
|
140
142
|
|