@defra-fish/connectors-lib 1.73.0 → 1.74.0-rc.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra-fish/connectors-lib",
3
- "version": "1.73.0",
3
+ "version": "1.74.0-rc.0",
4
4
  "description": "Shared connectors",
5
5
  "type": "module",
6
6
  "engines": {
@@ -47,5 +47,5 @@
47
47
  "node-fetch": "2.7.0",
48
48
  "redlock": "4.2.0"
49
49
  },
50
- "gitHead": "128d150e6bd30468bca4e39fb44dd11c5598eb43"
50
+ "gitHead": "4528014dc6eea3407641b04f1733bf01ed297a5e"
51
51
  }
@@ -2,25 +2,33 @@ import * as govUkPayApi from '../govuk-pay-api.js'
2
2
  jest.mock('node-fetch')
3
3
  const fetch = require('node-fetch')
4
4
 
5
- process.env.GOV_PAY_API_URL = 'http://0.0.0.0/payment'
6
- process.env.GOV_PAY_RCP_API_URL = 'http://0.0.0.0/agreement'
7
- process.env.GOV_PAY_APIKEY = 'key'
8
- process.env.GOV_PAY_RECURRING_APIKEY = 'recurringkey'
5
+ const envVars = Object.freeze({
6
+ GOV_PAY_API_URL: 'http://0.0.0.0/payment',
7
+ GOV_PAY_RCP_API_URL: 'http://0.0.0.0/agreement',
8
+ GOV_PAY_APIKEY: 'key',
9
+ GOV_PAY_RECURRING_APIKEY: 'recurringkey'
10
+ })
9
11
 
10
- const headers = {
12
+ const headers = () => ({
11
13
  accept: 'application/json',
12
14
  authorization: `Bearer ${process.env.GOV_PAY_APIKEY}`,
13
15
  'content-type': 'application/json'
14
- }
16
+ })
15
17
 
16
- const recurringHeaders = {
18
+ const recurringHeaders = () => ({
17
19
  accept: 'application/json',
18
20
  authorization: `Bearer ${process.env.GOV_PAY_RECURRING_APIKEY}`,
19
21
  'content-type': 'application/json'
20
- }
22
+ })
21
23
 
22
24
  describe('govuk-pay-api-connector', () => {
23
- beforeEach(jest.clearAllMocks)
25
+ beforeEach(() => {
26
+ jest.clearAllMocks()
27
+ for (const [key, value] of Object.entries(envVars)) {
28
+ process.env[key] = value
29
+ }
30
+ delete process.env.GOV_PAY_REQUEST_TIMEOUT_MS
31
+ })
24
32
 
25
33
  describe('createPayment', () => {
26
34
  it('creates new payments', async () => {
@@ -28,7 +36,7 @@ describe('govuk-pay-api-connector', () => {
28
36
  await expect(govUkPayApi.createPayment({ cost: 0 })).resolves.toEqual({ ok: true, status: 200 })
29
37
  expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment', {
30
38
  body: JSON.stringify({ cost: 0 }),
31
- headers,
39
+ headers: headers(),
32
40
  method: 'post',
33
41
  timeout: 10000
34
42
  })
@@ -42,7 +50,7 @@ describe('govuk-pay-api-connector', () => {
42
50
  expect(govUkPayApi.createPayment({ cost: 0 })).rejects.toEqual(Error(''))
43
51
  expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment', {
44
52
  body: JSON.stringify({ cost: 0 }),
45
- headers,
53
+ headers: headers(),
46
54
  method: 'post',
47
55
  timeout: 10000
48
56
  })
@@ -54,7 +62,7 @@ describe('govuk-pay-api-connector', () => {
54
62
  await expect(govUkPayApi.createPayment({ cost: 0 }, true)).resolves.toEqual({ ok: true, status: 200 })
55
63
  expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment', {
56
64
  body: JSON.stringify({ cost: 0 }),
57
- headers: recurringHeaders,
65
+ headers: recurringHeaders(),
58
66
  method: 'post',
59
67
  timeout: 10000
60
68
  })
@@ -66,7 +74,7 @@ describe('govuk-pay-api-connector', () => {
66
74
  fetch.mockReturnValueOnce({ ok: true, status: 200, json: () => {} })
67
75
  await expect(govUkPayApi.fetchPaymentStatus(123)).resolves.toEqual(expect.objectContaining({ ok: true, status: 200 }))
68
76
  expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123', {
69
- headers,
77
+ headers: headers(),
70
78
  method: 'get',
71
79
  timeout: 10000
72
80
  })
@@ -78,7 +86,7 @@ describe('govuk-pay-api-connector', () => {
78
86
  throw new Error('')
79
87
  })
80
88
  await expect(govUkPayApi.fetchPaymentStatus(123)).rejects.toEqual(Error(''))
81
- expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123', { headers, method: 'get', timeout: 10000 })
89
+ expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123', { headers: headers(), method: 'get', timeout: 10000 })
82
90
  expect(consoleErrorSpy).toHaveBeenCalled()
83
91
  })
84
92
 
@@ -86,7 +94,7 @@ describe('govuk-pay-api-connector', () => {
86
94
  fetch.mockReturnValueOnce({ ok: true, status: 200, json: () => {} })
87
95
  await expect(govUkPayApi.fetchPaymentStatus(123, true)).resolves.toEqual(expect.objectContaining({ ok: true, status: 200 }))
88
96
  expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123', {
89
- headers: recurringHeaders,
97
+ headers: recurringHeaders(),
90
98
  method: 'get',
91
99
  timeout: 10000
92
100
  })
@@ -97,7 +105,7 @@ describe('govuk-pay-api-connector', () => {
97
105
  it('retrieves payment events', async () => {
98
106
  fetch.mockReturnValueOnce({ ok: true, status: 200, json: () => {} })
99
107
  await expect(govUkPayApi.fetchPaymentEvents(123)).resolves.toEqual(expect.objectContaining({ ok: true, status: 200 }))
100
- expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123/events', { headers, method: 'get', timeout: 10000 })
108
+ expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123/events', { headers: headers(), method: 'get', timeout: 10000 })
101
109
  })
102
110
 
103
111
  it('logs and throws errors', async () => {
@@ -113,7 +121,7 @@ describe('govuk-pay-api-connector', () => {
113
121
  fetch.mockReturnValueOnce({ ok: true, status: 200, json: () => {} })
114
122
  await expect(govUkPayApi.fetchPaymentEvents(123, true)).resolves.toEqual(expect.objectContaining({ ok: true, status: 200 }))
115
123
  expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/payment/123/events', {
116
- headers: recurringHeaders,
124
+ headers: recurringHeaders(),
117
125
  method: 'get',
118
126
  timeout: 10000
119
127
  })
@@ -126,7 +134,7 @@ describe('govuk-pay-api-connector', () => {
126
134
  await expect(govUkPayApi.createRecurringPaymentAgreement({ cost: 0 })).resolves.toEqual({ ok: true, status: 200 })
127
135
  expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/agreement', {
128
136
  body: JSON.stringify({ cost: 0 }),
129
- headers: recurringHeaders,
137
+ headers: recurringHeaders(),
130
138
  method: 'post',
131
139
  timeout: 10000
132
140
  })
@@ -140,7 +148,7 @@ describe('govuk-pay-api-connector', () => {
140
148
  expect(govUkPayApi.createRecurringPaymentAgreement({ reference: '123' })).rejects.toEqual(Error(''))
141
149
  expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/agreement', {
142
150
  body: JSON.stringify({ reference: '123' }),
143
- headers: recurringHeaders,
151
+ headers: recurringHeaders(),
144
152
  method: 'post',
145
153
  timeout: 10000
146
154
  })
@@ -148,6 +156,106 @@ describe('govuk-pay-api-connector', () => {
148
156
  })
149
157
  })
150
158
 
159
+ describe('queueRecurringPayment', () => {
160
+ const getSamplePreparedPayment = (overrides = {}) => ({
161
+ agreement_id: 'agreement_id',
162
+ ...overrides
163
+ })
164
+
165
+ it('queues a recurring payment', async () => {
166
+ const GOV_PAY_API_URL = 'GovPay API URL'
167
+ const GOV_PAY_REQUEST_TIMEOUT_MS = '12345'
168
+ const GOV_PAY_RECURRING_APIKEY = 'GovPay Recurring API Key'
169
+ process.env.GOV_PAY_API_URL = GOV_PAY_API_URL
170
+ process.env.GOV_PAY_REQUEST_TIMEOUT_MS = GOV_PAY_REQUEST_TIMEOUT_MS
171
+ process.env.GOV_PAY_RECURRING_APIKEY = GOV_PAY_RECURRING_APIKEY
172
+ const batcher = { addRequest: jest.fn() }
173
+ const preparedPayment = getSamplePreparedPayment({ cost: 0 })
174
+ govUkPayApi.queueRecurringPayment(preparedPayment, batcher)
175
+
176
+ expect(batcher.addRequest).toHaveBeenCalledWith(
177
+ GOV_PAY_API_URL,
178
+ expect.objectContaining({
179
+ headers: recurringHeaders(),
180
+ method: 'post',
181
+ body: JSON.stringify(preparedPayment),
182
+ timeout: GOV_PAY_REQUEST_TIMEOUT_MS
183
+ }),
184
+ expect.anything()
185
+ )
186
+ })
187
+
188
+ it('adds agreement id as a reference', async () => {
189
+ // eslint-disable-next-line camelcase
190
+ const agreement_id = Symbol('agreement_id')
191
+ const batcher = { addRequest: jest.fn() }
192
+ govUkPayApi.queueRecurringPayment({ cost: 0, reference: '123', agreement_id }, batcher)
193
+ expect(batcher.addRequest).toHaveBeenCalledWith(expect.any(String), expect.any(Object), agreement_id)
194
+ })
195
+
196
+ it("uses default timeout of 10000ms if GOV_PAY_REQUEST_TIMEOUT_MS isn't set", async () => {
197
+ delete process.env.GOV_PAY_REQUEST_TIMEOUT_MS
198
+ const batcher = { addRequest: jest.fn() }
199
+
200
+ govUkPayApi.queueRecurringPayment(getSamplePreparedPayment({ cost: 0 }), batcher)
201
+
202
+ expect(batcher.addRequest).toHaveBeenCalledWith(
203
+ expect.any(String),
204
+ expect.objectContaining({
205
+ timeout: 10000
206
+ }),
207
+ expect.anything()
208
+ )
209
+ })
210
+ })
211
+
212
+ describe('queueRecurringPaymentStatusCheck', () => {
213
+ it.each(['abc-123', 'def-456'])('queues a recurring payment status check with payment id %s', async paymentId => {
214
+ const GOV_PAY_API_URL = 'GovPay API URL'
215
+ const GOV_PAY_REQUEST_TIMEOUT_MS = '12345'
216
+ const GOV_PAY_RECURRING_APIKEY = 'GovPay Recurring API Key'
217
+ process.env.GOV_PAY_API_URL = GOV_PAY_API_URL
218
+ process.env.GOV_PAY_REQUEST_TIMEOUT_MS = GOV_PAY_REQUEST_TIMEOUT_MS
219
+ process.env.GOV_PAY_RECURRING_APIKEY = GOV_PAY_RECURRING_APIKEY
220
+ const batcher = { addRequest: jest.fn() }
221
+ govUkPayApi.queueRecurringPaymentStatusCheck(paymentId, batcher)
222
+
223
+ expect(batcher.addRequest).toHaveBeenCalledWith(
224
+ `${GOV_PAY_API_URL}/${paymentId}`,
225
+ expect.objectContaining({
226
+ headers: recurringHeaders(),
227
+ method: 'get',
228
+ timeout: GOV_PAY_REQUEST_TIMEOUT_MS
229
+ }),
230
+ expect.any(String)
231
+ )
232
+ })
233
+
234
+ it.each(['3c726d49-a1c0-42f0-88ca-78c0b9932bcd', '4122f2d9-b6ae-47ee-9590-c5d7f45fd9da'])(
235
+ 'adds payment id %s as a reference',
236
+ async paymentId => {
237
+ const batcher = { addRequest: jest.fn() }
238
+ govUkPayApi.queueRecurringPaymentStatusCheck(paymentId, batcher)
239
+ expect(batcher.addRequest).toHaveBeenCalledWith(expect.any(String), expect.any(Object), paymentId)
240
+ }
241
+ )
242
+
243
+ it("uses default timeout of 10000ms if GOV_PAY_REQUEST_TIMEOUT_MS isn't set", async () => {
244
+ delete process.env.GOV_PAY_REQUEST_TIMEOUT_MS
245
+ const batcher = { addRequest: jest.fn() }
246
+
247
+ govUkPayApi.queueRecurringPaymentStatusCheck('aaa-111', batcher)
248
+
249
+ expect(batcher.addRequest).toHaveBeenCalledWith(
250
+ expect.any(String),
251
+ expect.objectContaining({
252
+ timeout: 10000
253
+ }),
254
+ expect.any(String)
255
+ )
256
+ })
257
+ })
258
+
151
259
  describe('isGovPayUp', () => {
152
260
  it.each(['http://gov.uk.pay/health/check/url', 'https://gov-uk-pay?health-check-url'])(
153
261
  'calls healthy endpoint %s',
@@ -191,7 +299,7 @@ describe('govuk-pay-api-connector', () => {
191
299
  await expect(govUkPayApi.getRecurringPaymentAgreementInformation(123)).resolves.toEqual(
192
300
  expect.objectContaining({ ok: true, status: 200 })
193
301
  )
194
- expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/agreement/123', { headers: recurringHeaders, method: 'get', timeout: 10000 })
302
+ expect(fetch).toHaveBeenCalledWith('http://0.0.0.0/agreement/123', { headers: recurringHeaders(), method: 'get', timeout: 10000 })
195
303
  })
196
304
 
197
305
  it('logs and throws errors', async () => {
@@ -3,7 +3,7 @@ import fetch from 'node-fetch'
3
3
  import db from 'debug'
4
4
  const [{ value: debug }] = db.mock.results
5
5
 
6
- jest.mock('node-fetch', () => jest.fn(() => ({ status: 200 })))
6
+ jest.mock('node-fetch', () => jest.fn(() => Promise.resolve({ status: 200 })))
7
7
  jest.mock('debug', () => jest.fn(() => jest.fn()))
8
8
 
9
9
  describe('HTTP Request Batcher', () => {
@@ -34,7 +34,7 @@ describe('HTTP Request Batcher', () => {
34
34
 
35
35
  it('initialises with an empty response queue', () => {
36
36
  const batcher = new HTTPRequestBatcher()
37
- expect(batcher.responses).toEqual([])
37
+ expect(batcher.responseDetails).toEqual([])
38
38
  })
39
39
 
40
40
  it('initialises with a custom batch size', () => {
@@ -50,9 +50,39 @@ describe('HTTP Request Batcher', () => {
50
50
  batcher.addRequest('https://api-three.example.com', { method: 'PUT' })
51
51
 
52
52
  expect(batcher.requestQueue).toEqual([
53
- { url: 'https://api-one.example.com', options: { method: 'GET' } },
54
- { url: 'https://api-b.example.com', options: { method: 'POST' } },
55
- { url: 'https://api-three.example.com', options: { method: 'PUT' } }
53
+ expect.objectContaining({ url: 'https://api-one.example.com', options: { method: 'GET' } }),
54
+ expect.objectContaining({ url: 'https://api-b.example.com', options: { method: 'POST' } }),
55
+ expect.objectContaining({ url: 'https://api-three.example.com', options: { method: 'PUT' } })
56
+ ])
57
+ })
58
+
59
+ it('allows an optional reference to be added to a request', () => {
60
+ const reference1 = Symbol('ref-1')
61
+ const reference2 = Symbol('ref-2')
62
+ const batcher = new HTTPRequestBatcher()
63
+
64
+ batcher.addRequest('https://api-one.example.com', { method: 'GET' }, reference1)
65
+ batcher.addRequest('https://api-b.example.com', { method: 'POST' }, reference2)
66
+
67
+ expect(batcher.requestQueue).toEqual([
68
+ expect.objectContaining({ url: 'https://api-one.example.com', options: { method: 'GET' }, reference: reference1 }),
69
+ expect.objectContaining({ url: 'https://api-b.example.com', options: { method: 'POST' }, reference: reference2 })
70
+ ])
71
+ })
72
+
73
+ it('persists the reference in the responseDetails array after fetch', async () => {
74
+ const reference1 = Symbol('ref-1')
75
+ const reference2 = Symbol('ref-2')
76
+ const batcher = new HTTPRequestBatcher()
77
+
78
+ batcher.addRequest('https://api-one.example.com', { method: 'GET' }, reference1)
79
+ batcher.addRequest('https://api-b.example.com', { method: 'POST' }, reference2)
80
+
81
+ await batcher.fetch()
82
+
83
+ expect(batcher.responseDetails).toEqual([
84
+ expect.objectContaining({ url: 'https://api-one.example.com', options: { method: 'GET' }, reference: reference1 }),
85
+ expect.objectContaining({ url: 'https://api-b.example.com', options: { method: 'POST' }, reference: reference2 })
56
86
  ])
57
87
  })
58
88
 
@@ -99,13 +129,26 @@ describe('HTTP Request Batcher', () => {
99
129
  }
100
130
  await batcher.fetch()
101
131
 
102
- expect(batcher.responses).toEqual([{ status: 200 }, { status: 200 }])
132
+ expect(batcher.responseDetails.map(rd => rd.responses[0])).toEqual([{ status: 200 }, { status: 200 }])
133
+ })
134
+
135
+ it('pairs responses with the corresponding request', async () => {
136
+ const batcher = new HTTPRequestBatcher({ batchSize: 2 })
137
+ batcher.addRequest('https://api-one.example.com', { method: 'GET' })
138
+ batcher.addRequest('https://api-two.example.com', { method: 'POST' })
139
+ await batcher.fetch()
140
+
141
+ expect(batcher.responseDetails).toEqual([
142
+ expect.objectContaining({ responses: [{ status: 200 }], url: 'https://api-one.example.com', options: { method: 'GET' } }),
143
+ expect.objectContaining({ responses: [{ status: 200 }], url: 'https://api-two.example.com', options: { method: 'POST' } })
144
+ ])
103
145
  })
104
146
 
105
147
  describe('multiple batches', () => {
106
148
  beforeEach(() => {
107
149
  jest.useFakeTimers()
108
150
  jest.spyOn(global, 'setTimeout')
151
+ global.setTimeout.mockImplementation(cb => cb())
109
152
  })
110
153
 
111
154
  afterEach(() => {
@@ -166,20 +209,57 @@ describe('HTTP Request Batcher', () => {
166
209
  const batcher = new HTTPRequestBatcher({ batchSize: 1 })
167
210
  batcher.addRequest('https://api.example.com')
168
211
  batcher.addRequest('https://alt-api.example.com')
169
- global.setTimeout.mockImplementationOnce(cb => cb())
170
212
  await batcher.fetch()
171
- expect(batcher.responses).toEqual([{ status: 200 }, { status: 200 }])
213
+ expect(batcher.responseDetails.map(rd => rd.responses[0])).toEqual([{ status: 200 }, { status: 200 }])
172
214
  })
173
215
 
174
216
  it('retries requests that received a 429 response', async () => {
175
217
  const batcher = new HTTPRequestBatcher({ batchSize: 1 })
176
218
  fetch.mockImplementationOnce(() => ({ status: 429 }))
177
219
  batcher.addRequest('https://api.example.com')
178
- global.setTimeout.mockImplementationOnce(cb => cb())
179
220
  await batcher.fetch()
180
221
  expect(fetch).toHaveBeenCalledTimes(2)
181
222
  })
182
223
 
224
+ it.each([10, 7])('retries requests that received a 429 response up to configured maxRequestAttempts(%i)', async maxRequestAttempts => {
225
+ const batcher = new HTTPRequestBatcher({ batchSize: 1 })
226
+ batcher.maxRequestAttempts = maxRequestAttempts
227
+ for (let x = 0; x < maxRequestAttempts; x++) {
228
+ fetch.mockImplementationOnce(() => ({ status: 429 }))
229
+ }
230
+ batcher.addRequest('https://api.example.com')
231
+ await batcher.fetch()
232
+ expect(fetch).toHaveBeenCalledTimes(maxRequestAttempts)
233
+ })
234
+
235
+ it.each([10, 7])(
236
+ 'only retries until a successful response is received when maxRequestAttempts is set to %i',
237
+ async maxRequestAttempts => {
238
+ const batcher = new HTTPRequestBatcher({ batchSize: 1 })
239
+ batcher.maxRequestAttempts = maxRequestAttempts
240
+ for (let x = 0; x < maxRequestAttempts - 3; x++) {
241
+ fetch.mockImplementationOnce(() => ({ status: 429 }))
242
+ }
243
+ fetch.mockResolvedValueOnce({ status: 200 })
244
+ batcher.addRequest('https://api.example.com')
245
+
246
+ await batcher.fetch()
247
+
248
+ expect(fetch).toHaveBeenCalledTimes(maxRequestAttempts - 3 + 1)
249
+ }
250
+ )
251
+
252
+ it('stops retrying requests that received a 429 response once a successful response is received', async () => {
253
+ const batcher = new HTTPRequestBatcher({ batchSize: 1 })
254
+ batcher.maxRequestAttempts = 5
255
+ for (let x = 0; x < 4; x++) {
256
+ fetch.mockImplementationOnce(() => ({ status: 429 }))
257
+ }
258
+ batcher.addRequest('https://api.example.com')
259
+ await batcher.fetch()
260
+ expect(fetch).toHaveBeenCalledTimes(5)
261
+ })
262
+
183
263
  it('retries requests with the same options as the original request', async () => {
184
264
  const batcher = new HTTPRequestBatcher({ batchSize: 3 })
185
265
  fetch.mockResolvedValueOnce({ status: 200 }).mockResolvedValueOnce({ status: 429 })
@@ -188,7 +268,6 @@ describe('HTTP Request Batcher', () => {
188
268
  batcher.addRequest('https://alt-api.example.com', sampleOptions)
189
269
  batcher.addRequest('https://api-three.example.com')
190
270
  batcher.addRequest('https://api-four.example.com')
191
- global.setTimeout.mockImplementationOnce(cb => cb())
192
271
  await batcher.fetch()
193
272
  expect(fetch).toHaveBeenNthCalledWith(5, 'https://alt-api.example.com', sampleOptions)
194
273
  })
@@ -199,7 +278,6 @@ describe('HTTP Request Batcher', () => {
199
278
  batcher.addRequest('https://api.example.com')
200
279
  batcher.addRequest('https://alt-api.example.com')
201
280
  batcher.addRequest('https://api-three.example.com')
202
- global.setTimeout.mockImplementationOnce(cb => cb())
203
281
  await batcher.fetch()
204
282
  expect(batcher.batchSize).toBe(2)
205
283
  })
@@ -210,7 +288,6 @@ describe('HTTP Request Batcher', () => {
210
288
  batcher.addRequest('https://api.example.com')
211
289
  batcher.addRequest('https://alt-api.example.com')
212
290
  batcher.addRequest('https://api-three.example.com')
213
- global.setTimeout.mockImplementationOnce(cb => cb())
214
291
  await batcher.fetch()
215
292
  expect(debug).toHaveBeenCalledWith('429 response received for https://api.example.com, reducing batch size to 2')
216
293
  })
@@ -221,7 +298,6 @@ describe('HTTP Request Batcher', () => {
221
298
  batcher.addRequest('https://api.example.com')
222
299
  batcher.addRequest('https://api.example.com')
223
300
  batcher.addRequest('https://api.example.com')
224
- global.setTimeout.mockImplementationOnce(cb => cb())
225
301
  await batcher.fetch()
226
302
  expect(debug).toHaveBeenCalledWith(
227
303
  'Beginning batched fetch of 4 requests with initial batch size of 3 and delay between batches of 1000ms'
@@ -232,18 +308,54 @@ describe('HTTP Request Batcher', () => {
232
308
  const batcher = new HTTPRequestBatcher({ batchSize: 1 })
233
309
  fetch.mockImplementationOnce(() => ({ status: 429 }))
234
310
  batcher.addRequest('https://api.example.com')
235
- global.setTimeout.mockImplementationOnce(cb => cb())
236
311
  await batcher.fetch()
237
312
  expect(batcher.batchSize).toBe(1)
238
313
  })
239
314
 
240
315
  it('only retry once if a 429 response is received again', async () => {
241
316
  const batcher = new HTTPRequestBatcher({ batchSize: 1 })
242
- fetch.mockResolvedValueOnce({ status: 429 }).mockResolvedValueOnce({ status: 429 })
243
317
  batcher.addRequest('https://api.example.com')
244
- global.setTimeout.mockImplementation(cb => cb())
318
+ fetch.mockResolvedValueOnce({ status: 429 }).mockResolvedValueOnce({ status: 429 })
245
319
  await batcher.fetch()
246
320
  expect(fetch).toHaveBeenCalledTimes(2)
247
321
  })
322
+
323
+ it('sends all requests whether or not they fail', async () => {
324
+ fetch
325
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => {} })
326
+ .mockResolvedValueOnce({ ok: false, status: 500, json: () => {} })
327
+ .mockRejectedValueOnce(new Error('test event error'))
328
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => {} })
329
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => {} })
330
+ const batcher = new HTTPRequestBatcher()
331
+ batcher.addRequest('https://api.example.com/endpoint-1', { method: 'GET' })
332
+ batcher.addRequest('https://api.example.com/endpoint-3', { method: 'GET' })
333
+ batcher.addRequest('https://api.example.com/endpoint-2', { method: 'GET' })
334
+ batcher.addRequest('https://api.example.com/endpoint-gamma', { method: 'GET' })
335
+ batcher.addRequest('https://api.example.com/endpoint-alpha', { method: 'GET' })
336
+
337
+ await batcher.fetch()
338
+
339
+ expect(fetch).toHaveBeenCalledTimes(5)
340
+ expect(fetch).toHaveBeenNthCalledWith(1, 'https://api.example.com/endpoint-1', { method: 'GET' })
341
+ expect(fetch).toHaveBeenNthCalledWith(2, 'https://api.example.com/endpoint-3', { method: 'GET' })
342
+ expect(fetch).toHaveBeenNthCalledWith(3, 'https://api.example.com/endpoint-2', { method: 'GET' })
343
+ expect(fetch).toHaveBeenNthCalledWith(4, 'https://api.example.com/endpoint-gamma', { method: 'GET' })
344
+ expect(fetch).toHaveBeenNthCalledWith(5, 'https://api.example.com/endpoint-alpha', { method: 'GET' })
345
+ })
346
+
347
+ it('does not add more than one entry in responseDetails array for the same request when retrying a request', async () => {
348
+ fetch
349
+ .mockResolvedValueOnce({ ok: true, status: 200, json: () => {} })
350
+ .mockResolvedValueOnce({ ok: false, status: 429, json: () => {} })
351
+ const batcher = new HTTPRequestBatcher()
352
+ batcher.addRequest('https://api.example.com/endpoint-1', { method: 'GET' })
353
+ batcher.addRequest('https://api.example.com/endpoint-2', { method: 'GET' })
354
+
355
+ await batcher.fetch()
356
+
357
+ expect(batcher.responseDetails.length).toBe(2)
358
+ expect(batcher.responseDetails[0]).not.toBe(batcher.responseDetails[1])
359
+ })
248
360
  })
249
361
  })
@@ -32,6 +32,31 @@ export const createRecurringPaymentAgreement = async preparedPayment => {
32
32
  }
33
33
  }
34
34
 
35
+ export const queueRecurringPayment = (preparedPayment, batcher) => {
36
+ batcher.addRequest(
37
+ process.env.GOV_PAY_API_URL,
38
+ {
39
+ headers: headers(true),
40
+ method: 'post',
41
+ body: JSON.stringify(preparedPayment),
42
+ timeout: process.env.GOV_PAY_REQUEST_TIMEOUT_MS || GOV_PAY_REQUEST_TIMEOUT_MS_DEFAULT
43
+ },
44
+ preparedPayment.agreement_id
45
+ )
46
+ }
47
+
48
+ export const queueRecurringPaymentStatusCheck = (paymentId, batcher) => {
49
+ batcher.addRequest(
50
+ `${process.env.GOV_PAY_API_URL}/${paymentId}`,
51
+ {
52
+ headers: headers(true),
53
+ method: 'get',
54
+ timeout: process.env.GOV_PAY_REQUEST_TIMEOUT_MS || GOV_PAY_REQUEST_TIMEOUT_MS_DEFAULT
55
+ },
56
+ paymentId
57
+ )
58
+ }
59
+
35
60
  /**
36
61
  * Create a new payment
37
62
  * @param preparedPayment - see the GOV.UK pay API reference for details
@@ -4,10 +4,11 @@ import { StatusCodes } from 'http-status-codes'
4
4
 
5
5
  const debug = db('connectors:http-request-batcher')
6
6
  export default class HTTPRequestBatcher {
7
+ maxRequestAttempts = 2
7
8
  #batchSize
8
9
  #delay
9
10
  #requests = []
10
- #responses = []
11
+ #responseDetails = []
11
12
 
12
13
  constructor ({ batchSize = 50, delay = 1000 } = {}) {
13
14
  this.#batchSize = batchSize
@@ -22,34 +23,49 @@ export default class HTTPRequestBatcher {
22
23
  return this.#requests
23
24
  }
24
25
 
25
- get responses () {
26
- return this.#responses
26
+ get responseDetails () {
27
+ return this.#responseDetails
27
28
  }
28
29
 
29
30
  get delay () {
30
31
  return this.#delay
31
32
  }
32
33
 
33
- addRequest (url, options) {
34
+ addRequest (url, options, reference = null) {
34
35
  if (!url) {
35
36
  throw new Error('URL is required')
36
37
  }
37
- this.#requests.push({ url, options })
38
+ this.#requests.push({
39
+ url,
40
+ options,
41
+ reference,
42
+ responses: []
43
+ })
38
44
  }
39
45
 
40
- async _sendBatch (fetchRequests, sentRequests, requestQueue) {
41
- const batchResponses = await Promise.all(fetchRequests)
42
- this.#responses.push(...batchResponses)
43
- for (let x = 0; x < batchResponses.length; x++) {
44
- const response = batchResponses[x]
45
- if (response.status === StatusCodes.TOO_MANY_REQUESTS && sentRequests[x].attempts < 2) {
46
- requestQueue.push({ ...sentRequests[x], attempts: sentRequests[x].attempts + 1 })
46
+ async #processBatch (fetchRequests, requestQueue) {
47
+ for (const fetchRequest of fetchRequests) {
48
+ const response = await (async () => {
49
+ try {
50
+ return await fetchRequest.responsePromise
51
+ } catch (e) {
52
+ return e
53
+ }
54
+ })()
55
+ fetchRequest.responses.push(response)
56
+ if (
57
+ fetchRequest.responses.at(-1).status === StatusCodes.TOO_MANY_REQUESTS &&
58
+ fetchRequest.responses.length < this.maxRequestAttempts
59
+ ) {
60
+ requestQueue.push(fetchRequest)
47
61
  this.#batchSize = Math.max(this.#batchSize - 1, 1)
48
- debug(`429 response received for ${sentRequests[x].url}, reducing batch size to ${this.#batchSize}`)
62
+ debug(`${StatusCodes.TOO_MANY_REQUESTS} response received for ${fetchRequest.url}, reducing batch size to ${this.#batchSize}`)
63
+ }
64
+ if (!this.#responseDetails.includes(fetchRequest)) {
65
+ this.#responseDetails.push(fetchRequest)
49
66
  }
50
67
  }
51
68
  fetchRequests.length = 0
52
- sentRequests.length = 0
53
69
  if (requestQueue.length) {
54
70
  // don't wait if this is the last batch
55
71
  await new Promise(resolve => setTimeout(resolve, this.#delay))
@@ -63,14 +79,13 @@ export default class HTTPRequestBatcher {
63
79
  } and delay between batches of ${this.#delay}ms`
64
80
  )
65
81
  const requestQueue = [...this.#requests]
66
- const sentRequests = []
67
82
  const fetchRequests = []
68
83
  while (requestQueue.length) {
69
84
  const request = requestQueue.shift()
70
- fetchRequests.push(fetch(request.url, request.options))
71
- sentRequests.push({ attempts: 1, ...request })
72
- if (fetchRequests.length === this.#batchSize) {
73
- await this._sendBatch(fetchRequests, sentRequests, requestQueue)
85
+ request.responsePromise = fetch(request.url, request.options)
86
+ fetchRequests.push(request)
87
+ if (fetchRequests.length === this.#batchSize || requestQueue.length === 0) {
88
+ await this.#processBatch(fetchRequests, requestQueue)
74
89
  }
75
90
  }
76
91
  debug('Batched fetch complete')