soajs.controller 4.3.18 → 4.3.19
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/.github/workflows/ci.yml +4 -1
- package/.github/workflows/release.yml +4 -1
- package/docs/middleware/idempotency.md +31 -2
- package/mw/gotoService/redirectToService.js +20 -8
- package/mw/idempotency/index.js +42 -4
- package/mw/idempotency/model/memory.js +12 -2
- package/mw/idempotency/model/mongo.js +10 -3
- package/package.json +1 -1
- package/test/data/provision/customRegistry/gateway.js +32 -0
- package/test/data/provision/environment/dev.js +9 -0
- package/test/data/provision/hosts/idempotent.js +12 -0
- package/test/data/provision/marketplace/idempotent.js +37 -0
- package/test/data/provision/products/dsbrd.js +25 -0
- package/test/integration/UC6-idempotency/index.js +197 -0
- package/test/integration/index.js +4 -0
- package/test/unit/index.js +1 -0
- package/test/unit/mw/idempotency/index.js +122 -0
- package/test/unit/mw/idempotency/memory.js +36 -4
- package/test/unit/mw/idempotency/mongo.js +62 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -17,7 +17,10 @@ jobs:
|
|
|
17
17
|
|
|
18
18
|
services:
|
|
19
19
|
mongodb:
|
|
20
|
-
|
|
20
|
+
# TODO: back to mongo:8 once it starts on the GitHub runners again. Since 2026-10-19 mongod 8
|
|
21
|
+
# exits on start with "Linux kernel versions 6.19 and newer has a known incompatibility
|
|
22
|
+
# with this version of MongoDB" (https://jira.mongodb.org/browse/SERVER-121912).
|
|
23
|
+
image: mongo:7.0
|
|
21
24
|
ports:
|
|
22
25
|
- 27017:27017
|
|
23
26
|
|
|
@@ -17,7 +17,10 @@ jobs:
|
|
|
17
17
|
|
|
18
18
|
services:
|
|
19
19
|
mongodb:
|
|
20
|
-
|
|
20
|
+
# TODO: back to mongo:8 once it starts on the GitHub runners again. Since 2026-10-19 mongod 8
|
|
21
|
+
# exits on start with "Linux kernel versions 6.19 and newer has a known incompatibility
|
|
22
|
+
# with this version of MongoDB" (https://jira.mongodb.org/browse/SERVER-121912).
|
|
23
|
+
image: mongo:7.0
|
|
21
24
|
ports:
|
|
22
25
|
- 27017:27017
|
|
23
26
|
|
|
@@ -48,7 +48,8 @@ The Idempotency middleware prevents duplicate write operations by tracking reque
|
|
|
48
48
|
│
|
|
49
49
|
├─── Found (in_flight) ──▶ 409 Conflict (code: 181)
|
|
50
50
|
│
|
|
51
|
-
└─── Not found ──▶ Lock key → Execute → Store response
|
|
51
|
+
└─── Not found ──▶ Lock key → Execute → Store response (status < 500)
|
|
52
|
+
or release the key
|
|
52
53
|
```
|
|
53
54
|
|
|
54
55
|
## Configuration
|
|
@@ -140,12 +141,39 @@ Content-Type: application/json
|
|
|
140
141
|
{"caller": "user1", "callee": "user2"}
|
|
141
142
|
```
|
|
142
143
|
|
|
144
|
+
## How a Locked Key Ends
|
|
145
|
+
|
|
146
|
+
The key is locked before the request goes on to throttling, caching and the proxy. How the
|
|
147
|
+
request ends decides what happens to it:
|
|
148
|
+
|
|
149
|
+
| The request ends with | The key | A retry with the same key |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| An answer of the service below 500 (2xx, 3xx, 4xx) | Completed, the answer is stored for the TTL | Gets the stored answer, the service is not called |
|
|
152
|
+
| An answer of the service of 500 or more | Released | Reaches the service again |
|
|
153
|
+
| A proxy error (service down, connection closed) | Released | Reaches the service again |
|
|
154
|
+
| An answer of the gateway before the service: throttling (429), an error, a refusal | Released | Runs again |
|
|
155
|
+
| The client disconnecting before the request reaches the service | Released | Runs again |
|
|
156
|
+
| The client disconnecting while the service is answering | Follows the service answer above | |
|
|
157
|
+
|
|
158
|
+
Only an answer the service actually produced is replayed. A 4xx is the service's answer to that
|
|
159
|
+
request and replaying it is correct. A 5xx, a throttling refusal or any other answer of the gateway
|
|
160
|
+
says nothing about the operation, so the key is released and the client's retry runs instead of
|
|
161
|
+
getting the same refusal for the whole TTL.
|
|
162
|
+
|
|
163
|
+
While the request is running, a duplicate with the same key gets 409, code 181.
|
|
164
|
+
|
|
165
|
+
A service that can fail with a 5xx after part of the work is done must protect that work itself,
|
|
166
|
+
the retry reaches it again.
|
|
167
|
+
|
|
168
|
+
The release only removes a key still in flight and locked by that request: never a completed
|
|
169
|
+
one, and never a lock taken by a retry after the TTL of the first ran out.
|
|
170
|
+
|
|
143
171
|
## Response Scenarios
|
|
144
172
|
|
|
145
173
|
### New Request (First Time)
|
|
146
174
|
|
|
147
175
|
```
|
|
148
|
-
Request processed normally,
|
|
176
|
+
Request processed normally, a service answer below 500 is stored
|
|
149
177
|
```
|
|
150
178
|
|
|
151
179
|
### Replay (Completed)
|
|
@@ -235,6 +263,7 @@ Keys are isolated per tenant to prevent cross-tenant collisions.
|
|
|
235
263
|
- Only applies to non-GET requests (POST, PUT, DELETE, PATCH)
|
|
236
264
|
- GET requests always pass through (idempotent by nature)
|
|
237
265
|
- Keys expire after TTL (default 60 seconds)
|
|
266
|
+
- A key is never left in flight by a request that does not reach the service, see [How a Locked Key Ends](#how-a-locked-key-ends)
|
|
238
267
|
- Clients should generate a new UUID for each unique operation
|
|
239
268
|
- Same UUID = same operation (for retries)
|
|
240
269
|
- MongoDB backend required for horizontal scaling
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
|
|
12
12
|
|
|
13
13
|
const { httpRequestLight, proxyRequestMonitor } = require("../../lib/request.js");
|
|
14
|
+
const idempotency_mw = require("../idempotency/index.js");
|
|
14
15
|
|
|
15
16
|
// Monitor buffer for batch uploads
|
|
16
17
|
let monitorBuffer = [];
|
|
@@ -395,6 +396,11 @@ module.exports = (configuration) => {
|
|
|
395
396
|
};
|
|
396
397
|
}
|
|
397
398
|
|
|
399
|
+
//NOTE: from here the outcome of the service decides the key, the release on finish in the
|
|
400
|
+
// idempotency middleware stands down
|
|
401
|
+
if (idempotencyContext) {
|
|
402
|
+
idempotencyContext.proxied = true;
|
|
403
|
+
}
|
|
398
404
|
proxyRequestMonitor(req, res, requestOptions, extraOptions)
|
|
399
405
|
.then(() => {
|
|
400
406
|
// Save to cache if applicable
|
|
@@ -408,14 +414,20 @@ module.exports = (configuration) => {
|
|
|
408
414
|
cacheContext.model.set(cacheContext.key, cacheData, cacheContext.ttl);
|
|
409
415
|
}
|
|
410
416
|
// Complete idempotency if applicable
|
|
417
|
+
//NOTE: only an answer of the service is replayed, a 5xx means it could not answer, release
|
|
418
|
+
// the key so the retry reaches the service again
|
|
411
419
|
if (idempotencyContext) {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
420
|
+
if (idempotencyResponseStatusCode < 500) {
|
|
421
|
+
let idempotencyBody = Buffer.concat(idempotencyResponseChunks).toString('utf8');
|
|
422
|
+
let idempotencyData = {
|
|
423
|
+
statusCode: idempotencyResponseStatusCode,
|
|
424
|
+
headers: idempotencyResponseHeaders,
|
|
425
|
+
body: idempotencyBody
|
|
426
|
+
};
|
|
427
|
+
idempotencyContext.model.complete(idempotencyContext.key, idempotencyData, idempotencyContext.ttl);
|
|
428
|
+
} else {
|
|
429
|
+
idempotency_mw.unlock(idempotencyContext.model, idempotencyContext.key, idempotencyContext.lockId, req.soajs.log);
|
|
430
|
+
}
|
|
419
431
|
}
|
|
420
432
|
if (monitor && !monitor_service_blacklist && monitor.req_response) {
|
|
421
433
|
monitoObj.time.res_end = new Date().getTime();
|
|
@@ -430,7 +442,7 @@ module.exports = (configuration) => {
|
|
|
430
442
|
}
|
|
431
443
|
// Unlock idempotency on error
|
|
432
444
|
if (idempotencyContext) {
|
|
433
|
-
idempotencyContext.model.
|
|
445
|
+
idempotency_mw.unlock(idempotencyContext.model, idempotencyContext.key, idempotencyContext.lockId, req.soajs.log);
|
|
434
446
|
}
|
|
435
447
|
if (!req.soajs.controller.monitorEndingReq) {
|
|
436
448
|
req.soajs.controllerResponse(core.error.getError(135));
|
package/mw/idempotency/index.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* found in the LICENSE file at the root of this repository
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
const crypto = require('crypto');
|
|
11
12
|
const get = (p, o) => p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o);
|
|
12
13
|
const memory = require('./model/memory.js');
|
|
13
14
|
const mongo = require('./model/mongo.js');
|
|
@@ -56,6 +57,21 @@ function matchesApi(method, path, apis) {
|
|
|
56
57
|
return false;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Release a key this request locked, only while it is still in flight and still ours
|
|
62
|
+
* @param modelObj memory or mongo
|
|
63
|
+
* @param key
|
|
64
|
+
* @param lockId
|
|
65
|
+
* @param log
|
|
66
|
+
*/
|
|
67
|
+
function unlock(modelObj, key, lockId, log) {
|
|
68
|
+
Promise.resolve()
|
|
69
|
+
.then(() => modelObj.unlock(key, lockId))
|
|
70
|
+
.catch((err) => {
|
|
71
|
+
log.error('Idempotency unlock:', err.message);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
59
75
|
module.exports = function (configuration) {
|
|
60
76
|
model.memory.init(configuration.log);
|
|
61
77
|
model.mongo.init(configuration.log, configuration.gatewayDB);
|
|
@@ -131,6 +147,7 @@ module.exports = function (configuration) {
|
|
|
131
147
|
'l2': idempotencyKey
|
|
132
148
|
};
|
|
133
149
|
|
|
150
|
+
let lockId = null;
|
|
134
151
|
const processIdempotency = async () => {
|
|
135
152
|
try {
|
|
136
153
|
const existing = await model[modelType].get(key);
|
|
@@ -152,7 +169,8 @@ module.exports = function (configuration) {
|
|
|
152
169
|
}
|
|
153
170
|
}
|
|
154
171
|
|
|
155
|
-
|
|
172
|
+
lockId = crypto.randomUUID();
|
|
173
|
+
const locked = await model[modelType].lock(key, ttl, lockId);
|
|
156
174
|
if (!locked) {
|
|
157
175
|
req.soajs.controllerResponse({
|
|
158
176
|
'status': 409,
|
|
@@ -163,18 +181,37 @@ module.exports = function (configuration) {
|
|
|
163
181
|
}
|
|
164
182
|
|
|
165
183
|
// Store idempotency context for gotoService to use
|
|
166
|
-
|
|
184
|
+
const context = {
|
|
167
185
|
key: key,
|
|
168
186
|
ttl: ttl,
|
|
187
|
+
lockId: lockId,
|
|
169
188
|
modelType: modelType,
|
|
170
189
|
model: model[modelType],
|
|
171
|
-
log: configuration.log
|
|
190
|
+
log: configuration.log,
|
|
191
|
+
proxied: false
|
|
172
192
|
};
|
|
193
|
+
req.soajs.idempotencyContext = context;
|
|
194
|
+
|
|
195
|
+
//NOTE: a request answered before it reaches the service, throttled, refused or dropped by
|
|
196
|
+
// the client, must not keep the key in flight until the ttl, release it once the response
|
|
197
|
+
// is over. A proxied request is completed or released by gotoService, never here.
|
|
198
|
+
let released = false;
|
|
199
|
+
const release = () => {
|
|
200
|
+
if (released || context.proxied) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
released = true;
|
|
204
|
+
unlock(model[modelType], key, lockId, configuration.log);
|
|
205
|
+
};
|
|
206
|
+
res.on('finish', release);
|
|
207
|
+
res.on('close', release);
|
|
173
208
|
|
|
174
209
|
next();
|
|
175
210
|
} catch (err) {
|
|
176
211
|
configuration.log.error('Idempotency error:', err);
|
|
177
|
-
|
|
212
|
+
if (lockId) {
|
|
213
|
+
unlock(model[modelType], key, lockId, configuration.log);
|
|
214
|
+
}
|
|
178
215
|
next();
|
|
179
216
|
}
|
|
180
217
|
};
|
|
@@ -185,3 +222,4 @@ module.exports = function (configuration) {
|
|
|
185
222
|
|
|
186
223
|
// Export model for external use
|
|
187
224
|
module.exports.model = model;
|
|
225
|
+
module.exports.unlock = unlock;
|
|
@@ -75,7 +75,7 @@ let model = {
|
|
|
75
75
|
return entry;
|
|
76
76
|
},
|
|
77
77
|
|
|
78
|
-
'lock': (key, ttl) => {
|
|
78
|
+
'lock': (key, ttl, lockId) => {
|
|
79
79
|
if (!dataHolder[key.l1]) {
|
|
80
80
|
dataHolder[key.l1] = {};
|
|
81
81
|
}
|
|
@@ -85,6 +85,7 @@ let model = {
|
|
|
85
85
|
}
|
|
86
86
|
dataHolder[key.l1][key.l2] = {
|
|
87
87
|
'status': 'in_flight',
|
|
88
|
+
'lockId': lockId || null,
|
|
88
89
|
'lockedAt': Date.now(),
|
|
89
90
|
'expiresAt': Date.now() + ttl
|
|
90
91
|
};
|
|
@@ -103,8 +104,17 @@ let model = {
|
|
|
103
104
|
};
|
|
104
105
|
},
|
|
105
106
|
|
|
106
|
-
|
|
107
|
+
//NOTE: only an entry still in flight is removed, never a completed one, and with a lockId only
|
|
108
|
+
// the lock of that request, not one taken by a retry after the ttl
|
|
109
|
+
'unlock': (key, lockId) => {
|
|
107
110
|
if (dataHolder[key.l1] && dataHolder[key.l1][key.l2]) {
|
|
111
|
+
const entry = dataHolder[key.l1][key.l2];
|
|
112
|
+
if (entry.status !== 'in_flight') {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (lockId && entry.lockId !== lockId) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
108
118
|
delete dataHolder[key.l1][key.l2];
|
|
109
119
|
}
|
|
110
120
|
},
|
|
@@ -58,7 +58,7 @@ let model = {
|
|
|
58
58
|
}
|
|
59
59
|
},
|
|
60
60
|
|
|
61
|
-
'lock': async (key, ttl) => {
|
|
61
|
+
'lock': async (key, ttl, lockId) => {
|
|
62
62
|
let condition = {
|
|
63
63
|
'l1': key.l1,
|
|
64
64
|
'l2': key.l2
|
|
@@ -73,6 +73,7 @@ let model = {
|
|
|
73
73
|
'l1': key.l1,
|
|
74
74
|
'l2': key.l2,
|
|
75
75
|
'status': 'in_flight',
|
|
76
|
+
'lockId': lockId || null,
|
|
76
77
|
'lockedAt': new Date(),
|
|
77
78
|
'expiresAt': new Date(Date.now() + ttl)
|
|
78
79
|
};
|
|
@@ -108,11 +109,17 @@ let model = {
|
|
|
108
109
|
}
|
|
109
110
|
},
|
|
110
111
|
|
|
111
|
-
|
|
112
|
+
//NOTE: only a record still in flight is removed, never a completed one, and with a lockId only
|
|
113
|
+
// the lock of that request, not one taken by a retry after the ttl
|
|
114
|
+
'unlock': async (key, lockId) => {
|
|
112
115
|
let condition = {
|
|
113
116
|
'l1': key.l1,
|
|
114
|
-
'l2': key.l2
|
|
117
|
+
'l2': key.l2,
|
|
118
|
+
'status': 'in_flight'
|
|
115
119
|
};
|
|
120
|
+
if (lockId) {
|
|
121
|
+
condition.lockId = lockId;
|
|
122
|
+
}
|
|
116
123
|
try {
|
|
117
124
|
await model.mongo.deleteOne(idempotency_store, condition);
|
|
118
125
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
let doc = {
|
|
3
|
+
"_id": "5d7f8e43edf21900fed08cf7",
|
|
4
|
+
"name": "gateway",
|
|
5
|
+
"locked": false,
|
|
6
|
+
"plugged": true,
|
|
7
|
+
"shared": false,
|
|
8
|
+
"value": {
|
|
9
|
+
"idempotency": {
|
|
10
|
+
"model": "mongo",
|
|
11
|
+
"services": {
|
|
12
|
+
"idempotent": {
|
|
13
|
+
"enabled": true,
|
|
14
|
+
"ttl": 60000,
|
|
15
|
+
"apis": ["POST /ok", "POST /fail", "POST /bad", "POST /slow", "POST /throttled"]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"traffic": {
|
|
20
|
+
"throttling": {
|
|
21
|
+
"idempotent": {
|
|
22
|
+
"publicAPIStrategy": "idempotent_tight",
|
|
23
|
+
"apis": ["/throttled"]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"created": "DEV",
|
|
29
|
+
"author": "owner"
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
module.exports = doc;
|
|
@@ -115,6 +115,15 @@ let env = {
|
|
|
115
115
|
"accessTokenLifetime": 7200,
|
|
116
116
|
"refreshTokenLifetime": 1209600
|
|
117
117
|
},
|
|
118
|
+
"throttling": {
|
|
119
|
+
"idempotent_tight": {
|
|
120
|
+
"type": 1,
|
|
121
|
+
"window": 60000,
|
|
122
|
+
"limit": 1,
|
|
123
|
+
"retries": 0,
|
|
124
|
+
"delay": 100
|
|
125
|
+
}
|
|
126
|
+
},
|
|
118
127
|
"ports": {
|
|
119
128
|
"controller": 4000,
|
|
120
129
|
"maintenanceInc": 1001,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
let service = {
|
|
4
|
+
_id: "5da078c92357684b57effcf2",
|
|
5
|
+
"type": "service",
|
|
6
|
+
name: "idempotent",
|
|
7
|
+
configuration: {
|
|
8
|
+
group: "SOAJS test",
|
|
9
|
+
port: 4014,
|
|
10
|
+
requestTimeout: 30,
|
|
11
|
+
requestTimeoutRenewal: 5
|
|
12
|
+
},
|
|
13
|
+
versions: [{
|
|
14
|
+
"version": "1",
|
|
15
|
+
maintenance: {
|
|
16
|
+
"port": {
|
|
17
|
+
"type": "maintenance"
|
|
18
|
+
},
|
|
19
|
+
"readiness": "/heartbeat"
|
|
20
|
+
},
|
|
21
|
+
oauth: true,
|
|
22
|
+
extKeyRequired: true,
|
|
23
|
+
urac: true,
|
|
24
|
+
urac_Profile: false,
|
|
25
|
+
urac_ACL: false,
|
|
26
|
+
provision_ACL: false,
|
|
27
|
+
apis: [
|
|
28
|
+
{ l: "Answers 200", v: "/ok", m: "post", group: "Integration test" },
|
|
29
|
+
{ l: "Answers 503 the first time", v: "/fail", m: "post", group: "Integration test" },
|
|
30
|
+
{ l: "Answers 400", v: "/bad", m: "post", group: "Integration test" },
|
|
31
|
+
{ l: "Answers 200 after a delay", v: "/slow", m: "post", group: "Integration test" },
|
|
32
|
+
{ l: "Answers 200, throttled", v: "/throttled", m: "post", group: "Integration test" }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
};
|
|
37
|
+
module.exports = service;
|
|
@@ -9,6 +9,23 @@ let prod = {
|
|
|
9
9
|
"scope": {
|
|
10
10
|
"acl": {
|
|
11
11
|
"dev": {
|
|
12
|
+
"idempotent": {
|
|
13
|
+
"1": {
|
|
14
|
+
"access": true,
|
|
15
|
+
"post": [
|
|
16
|
+
{
|
|
17
|
+
"group": "Idempotent group",
|
|
18
|
+
"apis": {
|
|
19
|
+
"/ok": {},
|
|
20
|
+
"/fail": {},
|
|
21
|
+
"/bad": {},
|
|
22
|
+
"/slow": {},
|
|
23
|
+
"/throttled": {}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
12
29
|
"httpmethods": {
|
|
13
30
|
"1": {
|
|
14
31
|
"access": true,
|
|
@@ -200,6 +217,14 @@ let prod = {
|
|
|
200
217
|
"locked": true,
|
|
201
218
|
"acl": {
|
|
202
219
|
"dev": {
|
|
220
|
+
"idempotent": [
|
|
221
|
+
{
|
|
222
|
+
"version": "1",
|
|
223
|
+
"post": [
|
|
224
|
+
"Idempotent group"
|
|
225
|
+
]
|
|
226
|
+
}
|
|
227
|
+
],
|
|
203
228
|
"httpmethods": [
|
|
204
229
|
{
|
|
205
230
|
"version": "1",
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const express = require('express');
|
|
6
|
+
const request = require('request');
|
|
7
|
+
const helper = require("../../helper.js");
|
|
8
|
+
let Controller = helper.requireModule('./server/controller');
|
|
9
|
+
let localConfig = helper.requireModule('./config.js');
|
|
10
|
+
|
|
11
|
+
//NOTE: the gateway custom registry turns idempotency on with the mongo model for every route of the
|
|
12
|
+
// idempotent service, and throttles /throttled to one call per minute
|
|
13
|
+
const ownerToken = "cfb209a91b23896820f510aadbf1f4284b512123";
|
|
14
|
+
|
|
15
|
+
function startService(calls, callback) {
|
|
16
|
+
const sApp = express();
|
|
17
|
+
const mApp = express();
|
|
18
|
+
let failed = false;
|
|
19
|
+
let count = (route) => {
|
|
20
|
+
calls[route] = (calls[route] || 0) + 1;
|
|
21
|
+
};
|
|
22
|
+
sApp.post('/ok', (req, res) => {
|
|
23
|
+
count("ok");
|
|
24
|
+
res.json({ "result": true, "data": { "call": calls.ok } });
|
|
25
|
+
});
|
|
26
|
+
sApp.post('/fail', (req, res) => {
|
|
27
|
+
count("fail");
|
|
28
|
+
if (!failed) {
|
|
29
|
+
failed = true;
|
|
30
|
+
return res.status(503).json({ "result": false, "errors": { "codes": [503], "details": [{ "code": 503, "message": "unavailable" }] } });
|
|
31
|
+
}
|
|
32
|
+
res.json({ "result": true, "data": { "call": calls.fail } });
|
|
33
|
+
});
|
|
34
|
+
sApp.post('/bad', (req, res) => {
|
|
35
|
+
count("bad");
|
|
36
|
+
res.status(400).json({ "result": false, "errors": { "codes": [400], "details": [{ "code": 400, "message": "bad input" }] } });
|
|
37
|
+
});
|
|
38
|
+
sApp.post('/slow', (req, res) => {
|
|
39
|
+
count("slow");
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
res.json({ "result": true, "data": { "call": calls.slow } });
|
|
42
|
+
}, 1500);
|
|
43
|
+
});
|
|
44
|
+
sApp.post('/throttled', (req, res) => {
|
|
45
|
+
count("throttled");
|
|
46
|
+
res.json({ "result": true, "data": { "call": calls.throttled } });
|
|
47
|
+
});
|
|
48
|
+
mApp.get('/heartbeat', (req, res) => {
|
|
49
|
+
res.json({ "result": true, "ts": Date.now(), "service": { "service": "idempotent", "type": "rest", "route": "/heartbeat" } });
|
|
50
|
+
});
|
|
51
|
+
let sAppServer = sApp.listen(4014, () => {
|
|
52
|
+
let mAppServer = mApp.listen(5015, () => {
|
|
53
|
+
return callback({ "sAppServer": sAppServer, "mAppServer": mAppServer });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("Integration for Usecase 6 - idempotency", function () {
|
|
59
|
+
let extKey = "3d90163cf9d6b3076ad26aa5ed58556348069258e5c6c941ee0f18448b570ad1c5c790e2d2a1989680c55f4904e2005ff5f8e71606e4aa641e67882f4210ebbc5460ff305dcb36e6ec2a2299cf0448ef60b9e38f41950ec251c1cf41f05f3ce9";
|
|
60
|
+
|
|
61
|
+
let c = new Controller({
|
|
62
|
+
"serviceName": localConfig.serviceName,
|
|
63
|
+
"serviceVersion": localConfig.serviceVersion,
|
|
64
|
+
"serviceGroup": localConfig.serviceGroup,
|
|
65
|
+
"servicePort": localConfig.servicePort,
|
|
66
|
+
"maintenance": localConfig.maintenance
|
|
67
|
+
});
|
|
68
|
+
let registry = null;
|
|
69
|
+
let log = null;
|
|
70
|
+
let service = null;
|
|
71
|
+
let server = null;
|
|
72
|
+
let serverMaintenance = null;
|
|
73
|
+
let mockServers = null;
|
|
74
|
+
let calls = {};
|
|
75
|
+
|
|
76
|
+
function call(route, key, cb) {
|
|
77
|
+
request.post({
|
|
78
|
+
"uri": 'http://127.0.0.1:' + localConfig.servicePort + '/idempotent' + route,
|
|
79
|
+
"json": true,
|
|
80
|
+
"headers": {
|
|
81
|
+
"key": extKey,
|
|
82
|
+
"idempotency-key": key,
|
|
83
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2227.1 Safari/537.36"
|
|
84
|
+
},
|
|
85
|
+
"qs": { "access_token": ownerToken }
|
|
86
|
+
}, (error, response, body) => {
|
|
87
|
+
assert.ifError(error);
|
|
88
|
+
return cb(response.statusCode, body);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
before((done) => {
|
|
93
|
+
//NOTE: the unit tests run in the same process and connect the idempotency mongo model to a dummy
|
|
94
|
+
// database, the model connects only once, drop it so the gateway connects it to the real one
|
|
95
|
+
helper.requireModule('./mw/idempotency/model/mongo').mongo = null;
|
|
96
|
+
startService(calls, (servers) => {
|
|
97
|
+
mockServers = servers;
|
|
98
|
+
c.init((_registry, _log, _service, _server, _serverMaintenance) => {
|
|
99
|
+
registry = _registry;
|
|
100
|
+
log = _log;
|
|
101
|
+
service = _service;
|
|
102
|
+
server = _server;
|
|
103
|
+
serverMaintenance = _serverMaintenance;
|
|
104
|
+
c.start(registry, log, service, server, serverMaintenance, () => {
|
|
105
|
+
setTimeout(function () {
|
|
106
|
+
done();
|
|
107
|
+
}, 5000);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("a service answer is completed and replayed", function (done) {
|
|
114
|
+
let key = crypto.randomUUID();
|
|
115
|
+
call("/ok", key, (status, body) => {
|
|
116
|
+
assert.strictEqual(status, 200, JSON.stringify(body));
|
|
117
|
+
assert.strictEqual(calls.ok, 1);
|
|
118
|
+
call("/ok", key, (status, body) => {
|
|
119
|
+
assert.strictEqual(status, 200);
|
|
120
|
+
assert.strictEqual(body.data.call, 1);
|
|
121
|
+
assert.strictEqual(calls.ok, 1);
|
|
122
|
+
done();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("a 4xx of the service is completed and replayed", function (done) {
|
|
128
|
+
let key = crypto.randomUUID();
|
|
129
|
+
call("/bad", key, (status) => {
|
|
130
|
+
assert.strictEqual(status, 400);
|
|
131
|
+
call("/bad", key, (status) => {
|
|
132
|
+
assert.strictEqual(status, 400);
|
|
133
|
+
assert.strictEqual(calls.bad, 1);
|
|
134
|
+
done();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("a 5xx of the service releases the key, the retry reaches the service", function (done) {
|
|
140
|
+
let key = crypto.randomUUID();
|
|
141
|
+
call("/fail", key, (status) => {
|
|
142
|
+
assert.strictEqual(status, 503);
|
|
143
|
+
call("/fail", key, (status, body) => {
|
|
144
|
+
assert.strictEqual(status, 200, JSON.stringify(body));
|
|
145
|
+
assert.strictEqual(calls.fail, 2);
|
|
146
|
+
call("/fail", key, (status, body) => {
|
|
147
|
+
assert.strictEqual(status, 200);
|
|
148
|
+
assert.strictEqual(body.data.call, 2);
|
|
149
|
+
assert.strictEqual(calls.fail, 2);
|
|
150
|
+
done();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("a duplicate while the first is running gets 409/181", function (done) {
|
|
157
|
+
let key = crypto.randomUUID();
|
|
158
|
+
let first = null;
|
|
159
|
+
call("/slow", key, (status) => {
|
|
160
|
+
first = status;
|
|
161
|
+
});
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
call("/slow", key, (status, body) => {
|
|
164
|
+
assert.strictEqual(status, 409);
|
|
165
|
+
assert.deepStrictEqual(body.errors.codes, [181]);
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
assert.strictEqual(first, 200);
|
|
168
|
+
assert.strictEqual(calls.slow, 1);
|
|
169
|
+
done();
|
|
170
|
+
}, 2000);
|
|
171
|
+
});
|
|
172
|
+
}, 300);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("a throttled request releases the key, the retry is not refused with 409", function (done) {
|
|
176
|
+
call("/throttled", crypto.randomUUID(), (status, body) => {
|
|
177
|
+
assert.strictEqual(status, 200, JSON.stringify(body));
|
|
178
|
+
let key = crypto.randomUUID();
|
|
179
|
+
call("/throttled", key, (status) => {
|
|
180
|
+
assert.strictEqual(status, 429);
|
|
181
|
+
call("/throttled", key, (status, body) => {
|
|
182
|
+
assert.strictEqual(status, 429, JSON.stringify(body));
|
|
183
|
+
assert.strictEqual(calls.throttled, 1);
|
|
184
|
+
done();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
after((done) => {
|
|
191
|
+
mockServers.sAppServer.close();
|
|
192
|
+
mockServers.mAppServer.close();
|
|
193
|
+
c.stop(registry, log, service, server, serverMaintenance, () => {
|
|
194
|
+
done();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
package/test/unit/index.js
CHANGED
|
@@ -39,6 +39,7 @@ describe("Starting Gateway Unit test", () => {
|
|
|
39
39
|
require("./mw/traffic/index.js");
|
|
40
40
|
require("./mw/idempotency/index.js");
|
|
41
41
|
require("./mw/idempotency/memory.js");
|
|
42
|
+
require("./mw/idempotency/mongo.js");
|
|
42
43
|
require("./mw/cache/index.js");
|
|
43
44
|
require("./mw/cache/memory.js");
|
|
44
45
|
require("./mw/lastSeen/index.js");
|
|
@@ -590,4 +590,126 @@ describe("Unit test for: mw - idempotency", () => {
|
|
|
590
590
|
});
|
|
591
591
|
});
|
|
592
592
|
|
|
593
|
+
describe("release of the key when the request ends before the service", () => {
|
|
594
|
+
const EventEmitter = require('events');
|
|
595
|
+
|
|
596
|
+
function buildReq(idempotencyKey, answers) {
|
|
597
|
+
return {
|
|
598
|
+
headers: { 'idempotency-key': idempotencyKey },
|
|
599
|
+
method: 'POST',
|
|
600
|
+
soajs: {
|
|
601
|
+
tenant: { id: 'tenant-release' },
|
|
602
|
+
controller: { serviceParams: { name: 'test', serviceInfo: ['', 'test', 'test'] } },
|
|
603
|
+
registry: {
|
|
604
|
+
custom: {
|
|
605
|
+
gateway: {
|
|
606
|
+
value: {
|
|
607
|
+
idempotency: {
|
|
608
|
+
model: 'memory',
|
|
609
|
+
services: {
|
|
610
|
+
test: { enabled: true, ttl: 60000, apis: ['POST /test'] }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
controllerResponse: (data) => {
|
|
618
|
+
answers.push(data);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function buildRes() {
|
|
625
|
+
let res = new EventEmitter();
|
|
626
|
+
res.replayed = null;
|
|
627
|
+
res.writeHead = (statusCode) => {
|
|
628
|
+
res.replayed = statusCode;
|
|
629
|
+
return res;
|
|
630
|
+
};
|
|
631
|
+
res.end = () => {
|
|
632
|
+
};
|
|
633
|
+
return res;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Runs the middleware and resolves with "next" or the answer it gave
|
|
637
|
+
function run(functionMw, idempotencyKey) {
|
|
638
|
+
return new Promise((resolve) => {
|
|
639
|
+
let answers = [];
|
|
640
|
+
let req = buildReq(idempotencyKey, answers);
|
|
641
|
+
let res = buildRes();
|
|
642
|
+
functionMw(req, res, () => {
|
|
643
|
+
resolve({ "next": true, req, res, answers });
|
|
644
|
+
});
|
|
645
|
+
setTimeout(() => {
|
|
646
|
+
resolve({ "next": false, req, res, answers });
|
|
647
|
+
}, 20);
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
let functionMw = null;
|
|
652
|
+
before(() => {
|
|
653
|
+
functionMw = mw(mockConfig);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("an answer before the service, a 429 for example, releases the key", async () => {
|
|
657
|
+
let key = '550e8400-e29b-41d4-a716-446655440101';
|
|
658
|
+
let first = await run(functionMw, key);
|
|
659
|
+
assert.strictEqual(first.next, true);
|
|
660
|
+
first.res.emit('finish');
|
|
661
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
662
|
+
let retry = await run(functionMw, key);
|
|
663
|
+
assert.strictEqual(retry.next, true, JSON.stringify(retry.answers));
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("a client disconnect before the service releases the key", async () => {
|
|
667
|
+
let key = '550e8400-e29b-41d4-a716-446655440102';
|
|
668
|
+
let first = await run(functionMw, key);
|
|
669
|
+
assert.strictEqual(first.next, true);
|
|
670
|
+
first.res.emit('close');
|
|
671
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
672
|
+
let retry = await run(functionMw, key);
|
|
673
|
+
assert.strictEqual(retry.next, true, JSON.stringify(retry.answers));
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("a proxied request is left to gotoService, a duplicate still gets 409/181", async () => {
|
|
677
|
+
let key = '550e8400-e29b-41d4-a716-446655440103';
|
|
678
|
+
let first = await run(functionMw, key);
|
|
679
|
+
assert.strictEqual(first.next, true);
|
|
680
|
+
first.req.soajs.idempotencyContext.proxied = true;
|
|
681
|
+
first.res.emit('finish');
|
|
682
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
683
|
+
let duplicate = await run(functionMw, key);
|
|
684
|
+
assert.strictEqual(duplicate.next, false);
|
|
685
|
+
assert.strictEqual(duplicate.answers[0].status, 409);
|
|
686
|
+
assert.strictEqual(duplicate.answers[0].code, 181);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("a completed request is replayed, finish does not remove it", async () => {
|
|
690
|
+
let key = '550e8400-e29b-41d4-a716-446655440104';
|
|
691
|
+
let first = await run(functionMw, key);
|
|
692
|
+
let context = first.req.soajs.idempotencyContext;
|
|
693
|
+
context.proxied = true;
|
|
694
|
+
context.model.complete(context.key, { statusCode: 201, headers: {}, body: '{}' }, context.ttl);
|
|
695
|
+
first.res.emit('finish');
|
|
696
|
+
first.res.emit('close');
|
|
697
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
698
|
+
let retry = await run(functionMw, key);
|
|
699
|
+
assert.strictEqual(retry.next, false);
|
|
700
|
+
assert.strictEqual(retry.res.replayed, 201);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("the release of an expired lock does not remove the lock of a later retry", async () => {
|
|
704
|
+
let key = '550e8400-e29b-41d4-a716-446655440105';
|
|
705
|
+
let first = await run(functionMw, key);
|
|
706
|
+
let context = first.req.soajs.idempotencyContext;
|
|
707
|
+
// the ttl of the first lock ran out and a retry locked the key again
|
|
708
|
+
context.model.get(context.key).expiresAt = Date.now() - 1;
|
|
709
|
+
assert.strictEqual(context.model.lock(context.key, 60000, 'lock-of-the-retry'), true);
|
|
710
|
+
first.res.emit('finish');
|
|
711
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
712
|
+
assert.strictEqual(context.model.get(context.key).lockId, 'lock-of-the-retry');
|
|
713
|
+
});
|
|
714
|
+
});
|
|
593
715
|
});
|
|
@@ -16,10 +16,12 @@ describe("Unit test for: mw - idempotency/model/memory", () => {
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
afterEach((done) => {
|
|
19
|
-
// Clean up between tests
|
|
20
|
-
model.
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
// Clean up between tests, unlock leaves completed entries in place so reset the store
|
|
20
|
+
model.init({
|
|
21
|
+
debug: () => {},
|
|
22
|
+
info: () => {},
|
|
23
|
+
error: () => {}
|
|
24
|
+
});
|
|
23
25
|
done();
|
|
24
26
|
});
|
|
25
27
|
|
|
@@ -133,6 +135,36 @@ describe("Unit test for: mw - idempotency/model/memory", () => {
|
|
|
133
135
|
});
|
|
134
136
|
});
|
|
135
137
|
|
|
138
|
+
describe("unlock only an own lock in flight", () => {
|
|
139
|
+
it("should keep a completed entry", (done) => {
|
|
140
|
+
let key = { l1: 'tenant1', l2: 'key1' };
|
|
141
|
+
model.lock(key, 60000, 'lock-a');
|
|
142
|
+
model.complete(key, { statusCode: 200 }, 60000);
|
|
143
|
+
model.unlock(key, 'lock-a');
|
|
144
|
+
let result = model.get(key);
|
|
145
|
+
assert.strictEqual(result.status, 'completed');
|
|
146
|
+
done();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should keep the lock of another request", (done) => {
|
|
150
|
+
let key = { l1: 'tenant1', l2: 'key1' };
|
|
151
|
+
model.lock(key, 60000, 'lock-b');
|
|
152
|
+
model.unlock(key, 'lock-a');
|
|
153
|
+
let result = model.get(key);
|
|
154
|
+
assert.strictEqual(result.status, 'in_flight');
|
|
155
|
+
assert.strictEqual(result.lockId, 'lock-b');
|
|
156
|
+
done();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should remove its own lock", (done) => {
|
|
160
|
+
let key = { l1: 'tenant1', l2: 'key1' };
|
|
161
|
+
model.lock(key, 60000, 'lock-a');
|
|
162
|
+
model.unlock(key, 'lock-a');
|
|
163
|
+
assert.strictEqual(model.get(key), null);
|
|
164
|
+
done();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
136
168
|
describe("cleanupExpiredEntries", () => {
|
|
137
169
|
it("should remove expired entries", (done) => {
|
|
138
170
|
let key1 = { l1: 'tenant1', l2: 'key1' };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const helper = require("../../../helper.js");
|
|
4
|
+
const model = helper.requireModule('./mw/idempotency/model/mongo');
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
|
|
7
|
+
describe("Unit test for: mw - idempotency/model/mongo", () => {
|
|
8
|
+
|
|
9
|
+
let calls = null;
|
|
10
|
+
let originalMongo = null;
|
|
11
|
+
|
|
12
|
+
before(() => {
|
|
13
|
+
originalMongo = model.mongo;
|
|
14
|
+
model.log = {
|
|
15
|
+
debug: () => {},
|
|
16
|
+
info: () => {},
|
|
17
|
+
error: () => {}
|
|
18
|
+
};
|
|
19
|
+
model.mongo = {
|
|
20
|
+
findOne: async (col, condition) => {
|
|
21
|
+
calls.push({ "op": "findOne", "condition": condition });
|
|
22
|
+
return null;
|
|
23
|
+
},
|
|
24
|
+
updateOne: async (col, condition, s, options) => {
|
|
25
|
+
calls.push({ "op": "updateOne", "condition": condition, "s": s, "options": options });
|
|
26
|
+
},
|
|
27
|
+
deleteOne: async (col, condition) => {
|
|
28
|
+
calls.push({ "op": "deleteOne", "condition": condition });
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
after(() => {
|
|
33
|
+
model.mongo = originalMongo;
|
|
34
|
+
});
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
calls = [];
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("lock stores the lockId", async () => {
|
|
40
|
+
let locked = await model.lock({ l1: 'tenant1', l2: 'key1' }, 60000, 'lock-a');
|
|
41
|
+
assert.strictEqual(locked, true);
|
|
42
|
+
let update = calls.find((c) => c.op === "updateOne");
|
|
43
|
+
assert.strictEqual(update.s.$set.status, 'in_flight');
|
|
44
|
+
assert.strictEqual(update.s.$set.lockId, 'lock-a');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("unlock removes only an own record in flight", async () => {
|
|
48
|
+
await model.unlock({ l1: 'tenant1', l2: 'key1' }, 'lock-a');
|
|
49
|
+
assert.deepStrictEqual(calls, [{
|
|
50
|
+
"op": "deleteOne",
|
|
51
|
+
"condition": { l1: 'tenant1', l2: 'key1', status: 'in_flight', lockId: 'lock-a' }
|
|
52
|
+
}]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("unlock without a lockId still never removes a completed record", async () => {
|
|
56
|
+
await model.unlock({ l1: 'tenant1', l2: 'key1' });
|
|
57
|
+
assert.deepStrictEqual(calls, [{
|
|
58
|
+
"op": "deleteOne",
|
|
59
|
+
"condition": { l1: 'tenant1', l2: 'key1', status: 'in_flight' }
|
|
60
|
+
}]);
|
|
61
|
+
});
|
|
62
|
+
});
|