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.
@@ -17,7 +17,10 @@ jobs:
17
17
 
18
18
  services:
19
19
  mongodb:
20
- image: mongo:8
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
- image: mongo:8
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, response cached
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
- let idempotencyBody = Buffer.concat(idempotencyResponseChunks).toString('utf8');
413
- let idempotencyData = {
414
- statusCode: idempotencyResponseStatusCode,
415
- headers: idempotencyResponseHeaders,
416
- body: idempotencyBody
417
- };
418
- idempotencyContext.model.complete(idempotencyContext.key, idempotencyData, idempotencyContext.ttl);
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.unlock(idempotencyContext.key);
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));
@@ -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
- const locked = await model[modelType].lock(key, ttl);
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
- req.soajs.idempotencyContext = {
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
- model[modelType].unlock(key).catch(() => {});
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
- 'unlock': (key) => {
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
- 'unlock': async (key) => {
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "soajs.controller",
3
3
  "description": "soajs multi tenant API gateway",
4
- "version": "4.3.18",
4
+ "version": "4.3.19",
5
5
  "author": {
6
6
  "name": "soajs team",
7
7
  "email": "team@soajs.org"
@@ -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,12 @@
1
+ 'use strict';
2
+
3
+ let host = {
4
+ _id: "5da078e22357684b57effcf3",
5
+ env: "dev",
6
+ ip: "127.0.0.1",
7
+ name: "idempotent",
8
+ version: "1",
9
+ port: 4014
10
+ };
11
+
12
+ module.exports = host;
@@ -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
+ });
@@ -40,6 +40,10 @@ describe("starting integration tests", () => {
40
40
  // to test the tenant access resolver
41
41
  require("./UC5-tenant-access/index.js");
42
42
 
43
+
44
+ // to test idempotency with the mongo model
45
+ require("./UC6-idempotency/index.js");
46
+
43
47
  done();
44
48
  });
45
49
  });
@@ -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.unlock({ l1: 'tenant1', l2: 'key1' });
21
- model.unlock({ l1: 'tenant1', l2: 'key2' });
22
- model.unlock({ l1: 'tenant2', l2: 'key1' });
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
+ });