From 8e8b47734bba58a1ca53b50a471889ed522135e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tu=C4=9Fkan=20Boz?= Date: Tue, 12 May 2026 22:58:20 +0300 Subject: [PATCH 1/2] Add chaos behavior for probabilistic fault injection (refs #918) --- src/models/behaviors.js | 71 +++++++++++- src/views/docs/api/behaviors.ejs | 15 +++ src/views/docs/api/behaviors/chaos.ejs | 65 +++++++++++ test/models/behaviors/chaosTest.js | 154 +++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 src/views/docs/api/behaviors/chaos.ejs create mode 100644 test/models/behaviors/chaosTest.js diff --git a/src/models/behaviors.js b/src/models/behaviors.js index a11c7d103..d7b4d5caa 100644 --- a/src/models/behaviors.js +++ b/src/models/behaviors.js @@ -106,6 +106,28 @@ const fromSchema = { _required: true, _allowedTypes: { string: {} }, _additionalContext: 'a JavaScript function' + }, + chaos: { + errorRate: { + _required: false, + _allowedTypes: { number: {} }, + _additionalContext: 'probability between 0 and 1 of injecting an error response' + }, + errorStatusCode: { + _required: false, + _allowedTypes: { number: { nonNegativeInteger: true } }, + _additionalContext: 'the status code to return when injecting an error (defaults to 500)' + }, + latencyRate: { + _required: false, + _allowedTypes: { number: {} }, + _additionalContext: 'probability between 0 and 1 of injecting random latency' + }, + maxLatencyMs: { + _required: false, + _allowedTypes: { number: { nonNegativeInteger: true } }, + _additionalContext: 'maximum latency in milliseconds when injecting' + } } }; @@ -509,6 +531,52 @@ async function lookup (originalRequest, response, lookupConfig, logger) { return response; } +async function maybeInjectLatency (config, logger) { + const latencyRate = config.latencyRate || 0, + maxLatencyMs = config.maxLatencyMs || 0; + + if (latencyRate <= 0 || maxLatencyMs <= 0) { + return; + } + if (Math.random() >= latencyRate) { + return; + } + + const delay = Math.floor(Math.random() * maxLatencyMs); + logger.debug(`chaos: injecting ${delay}ms latency`); + await new Promise(resolve => setTimeout(resolve, delay)); +} + +function maybeInjectError (config, response, logger) { + const errorRate = config.errorRate || 0, + errorStatusCode = config.errorStatusCode || 500; + + if (errorRate <= 0 || Math.random() >= errorRate) { + return; + } + + logger.debug(`chaos: injecting error status ${errorStatusCode}`); + response.statusCode = errorStatusCode; + response.body = ''; +} + +/** + * Probabilistically injects faults into the response, useful for chaos engineering + * and resilience testing. Supports injecting errors (replacing the response with an + * error status code) and latency (delaying the response by a random amount up to maxLatencyMs). + * Both probabilities are independent and default to 0 (no chaos). + * @param {Object} request - The request + * @param {Object} response - The response + * @param {Object} config - The chaos configuration + * @param {Object} logger - The mountebank logger + * @returns {Promise} + */ +async function chaos (request, response, config, logger) { + await maybeInjectLatency(config, logger); + maybeInjectError(config, response, logger); + return response; +} + /** * The entry point to execute all behaviors provided in the API * @param {Object} request - The request object @@ -524,7 +592,8 @@ async function execute (request, response, behaviors, logger, imposterState) { copy: copy, lookup: lookup, shellTransform: shellTransform, - decorate: decorate + decorate: decorate, + chaos: chaos }; let result = Promise.resolve(response); diff --git a/src/views/docs/api/behaviors.ejs b/src/views/docs/api/behaviors.ejs index efc7be74a..ef2c26d7f 100644 --- a/src/views/docs/api/behaviors.ejs +++ b/src/views/docs/api/behaviors.ejs @@ -64,6 +64,12 @@ the following behaviors:

line flag must be set to support this behavior.

+ + chaos + Probabilistically injects faults into the response. Useful for chaos engineering + and resilience testing: random error responses, random latency, or both. All parameters + are optional and default to a no-op, so you can enable just the parts you need. +

Multiple behaviors can be added to a response, and they will be executed in array order. While @@ -136,6 +142,15 @@ response transformation.

<%- include('behaviors/shellTransform') -%> +
+ + chaos + +
+ <%- include('behaviors/chaos') -%> +
+
<%- include('../../_footer') -%> diff --git a/src/views/docs/api/behaviors/chaos.ejs b/src/views/docs/api/behaviors/chaos.ejs new file mode 100644 index 000000000..1b996ad46 --- /dev/null +++ b/src/views/docs/api/behaviors/chaos.ejs @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDescription
errorRateA number between 0 and 1The probability that the response will be replaced with an error. + Defaults to 0 (no error injection).
errorStatusCodeA non-negative integerThe status code to return when injecting an error. Defaults to 500.
latencyRateA number between 0 and 1The probability that random latency will be added to the response. + Defaults to 0 (no latency injection).
maxLatencyMsA non-negative integerThe maximum latency in milliseconds. When triggered, the injected latency + is a uniformly random value between 0 and this maximum.
+ +

The chaos behavior probabilistically injects faults into a response, +making it useful for resilience and chaos engineering tests. Error injection and +latency injection are independent: you can enable either or both. Every other field +is optional, defaulting to a behavior that does nothing.

+ +

Here is a stub that returns a 503 on roughly 10% of calls and adds up to 1 +second of latency on roughly 5% of calls:

+ +
{
+  "responses": [
+    {
+      "is": { "statusCode": 200, "body": "ok" },
+      "behaviors": [
+        {
+          "chaos": {
+            "errorRate": 0.1,
+            "errorStatusCode": 503,
+            "latencyRate": 0.05,
+            "maxLatencyMs": 1000
+          }
+        }
+      ]
+    }
+  ]
+}
+ +

Since the behavior is non-deterministic by design, repeated calls against the +same imposter will produce a mix of successful and faulty responses. This is the +whole point: it lets you exercise retry logic, circuit breakers, and timeout +handling in the system under test without standing up a separate fault-injection +proxy.

+ +

If both errorRate and latencyRate trigger on the same +request, latency is added first and then the response is replaced with the error.

diff --git a/test/models/behaviors/chaosTest.js b/test/models/behaviors/chaosTest.js new file mode 100644 index 000000000..4e76bf201 --- /dev/null +++ b/test/models/behaviors/chaosTest.js @@ -0,0 +1,154 @@ +'use strict'; + +const assert = require('assert'), + behaviors = require('../../../src/models/behaviors'), + Logger = require('../../fakes/fakeLogger'); + +describe('behaviors', function () { + describe('#chaos', function () { + let originalRandom; + + beforeEach(function () { + originalRandom = Math.random; + }); + + afterEach(function () { + Math.random = originalRandom; + }); + + it('should not execute during dry run', async function () { + Math.random = () => 0; + const request = { isDryRun: true }, + response = { statusCode: 200, body: 'ok' }, + logger = Logger.create(), + config = { chaos: { errorRate: 1, errorStatusCode: 503 } }, + actualResponse = await behaviors.execute(request, response, [config], logger); + + assert.deepEqual(actualResponse, { statusCode: 200, body: 'ok' }); + }); + + it('should pass response through unchanged when no probabilities are set', async function () { + Math.random = () => 0; + const request = {}, + response = { statusCode: 200, body: 'ok' }, + logger = Logger.create(), + config = { chaos: {} }, + actualResponse = await behaviors.execute(request, response, [config], logger); + + assert.deepEqual(actualResponse, { statusCode: 200, body: 'ok' }); + }); + + it('should inject configured error status when errorRate triggers', async function () { + Math.random = () => 0; + const request = {}, + response = { statusCode: 200, body: 'ok' }, + logger = Logger.create(), + config = { chaos: { errorRate: 1, errorStatusCode: 503 } }, + actualResponse = await behaviors.execute(request, response, [config], logger); + + assert.strictEqual(actualResponse.statusCode, 503); + assert.strictEqual(actualResponse.body, ''); + }); + + it('should default errorStatusCode to 500 when not provided', async function () { + Math.random = () => 0; + const request = {}, + response = { statusCode: 200, body: 'ok' }, + logger = Logger.create(), + config = { chaos: { errorRate: 1 } }, + actualResponse = await behaviors.execute(request, response, [config], logger); + + assert.strictEqual(actualResponse.statusCode, 500); + assert.strictEqual(actualResponse.body, ''); + }); + + it('should not inject error when random draw is above errorRate', async function () { + Math.random = () => 0.99; + const request = {}, + response = { statusCode: 200, body: 'ok' }, + logger = Logger.create(), + config = { chaos: { errorRate: 0.5, errorStatusCode: 503 } }, + actualResponse = await behaviors.execute(request, response, [config], logger); + + assert.deepEqual(actualResponse, { statusCode: 200, body: 'ok' }); + }); + + it('should not inject error when errorRate is 0', async function () { + Math.random = () => 0; + const request = {}, + response = { statusCode: 200, body: 'ok' }, + logger = Logger.create(), + config = { chaos: { errorRate: 0, errorStatusCode: 503 } }, + actualResponse = await behaviors.execute(request, response, [config], logger); + + assert.deepEqual(actualResponse, { statusCode: 200, body: 'ok' }); + }); + + it('should inject latency when latencyRate triggers', async function () { + let calls = 0; + Math.random = () => { + calls += 1; + return calls === 1 ? 0 : 0.99; + }; + const request = {}, + response = { statusCode: 200, body: 'ok' }, + logger = Logger.create(), + start = Date.now(), + config = { chaos: { latencyRate: 1, maxLatencyMs: 100 } }; + + const actualResponse = await behaviors.execute(request, response, [config], logger), + elapsed = Date.now() - start; + + assert.ok(elapsed >= 90, `expected latency >=90ms, got ${elapsed}ms`); + assert.deepEqual(actualResponse, { statusCode: 200, body: 'ok' }); + }); + + it('should skip latency when maxLatencyMs is 0', async function () { + Math.random = () => 0; + const request = {}, + response = { statusCode: 200, body: 'ok' }, + logger = Logger.create(), + start = Date.now(), + config = { chaos: { latencyRate: 1, maxLatencyMs: 0 } }; + + await behaviors.execute(request, response, [config], logger); + const elapsed = Date.now() - start; + + assert.ok(elapsed < 50, `expected near-instant, got ${elapsed}ms`); + }); + + it('should not be valid if errorRate is not a number', function () { + const errors = behaviors.validate([{ chaos: { errorRate: 'oops' } }]); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].code, 'bad data'); + assert.ok(errors[0].message.indexOf('errorRate') >= 0, + `unexpected message: ${errors[0].message}`); + }); + + it('should not be valid if maxLatencyMs is negative', function () { + const errors = behaviors.validate([{ chaos: { maxLatencyMs: -1 } }]); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].code, 'bad data'); + assert.ok(errors[0].message.indexOf('maxLatencyMs') >= 0, + `unexpected message: ${errors[0].message}`); + }); + + it('should not be valid if errorStatusCode is negative', function () { + const errors = behaviors.validate([{ chaos: { errorStatusCode: -1 } }]); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].code, 'bad data'); + }); + + it('should be valid with all fields set to reasonable values', function () { + const errors = behaviors.validate([{ + chaos: { + errorRate: 0.1, + errorStatusCode: 503, + latencyRate: 0.05, + maxLatencyMs: 1000 + } + }]); + assert.deepEqual(errors, []); + }); + }); +}); From b5f9a35a45644c11d5853f3761c6d13b603a2aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tu=C4=9Fkan=20Boz?= Date: Tue, 12 May 2026 23:01:59 +0300 Subject: [PATCH 2/2] Fix SonarCloud findings in chaos tests: node:assert, .includes, split await --- test/models/behaviors/chaosTest.js | 36 ++++++++++++++---------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/test/models/behaviors/chaosTest.js b/test/models/behaviors/chaosTest.js index 4e76bf201..0c12c0a4b 100644 --- a/test/models/behaviors/chaosTest.js +++ b/test/models/behaviors/chaosTest.js @@ -1,6 +1,6 @@ 'use strict'; -const assert = require('assert'), +const assert = require('node:assert'), behaviors = require('../../../src/models/behaviors'), Logger = require('../../fakes/fakeLogger'); @@ -21,8 +21,8 @@ describe('behaviors', function () { const request = { isDryRun: true }, response = { statusCode: 200, body: 'ok' }, logger = Logger.create(), - config = { chaos: { errorRate: 1, errorStatusCode: 503 } }, - actualResponse = await behaviors.execute(request, response, [config], logger); + config = { chaos: { errorRate: 1, errorStatusCode: 503 } }; + const actualResponse = await behaviors.execute(request, response, [config], logger); assert.deepEqual(actualResponse, { statusCode: 200, body: 'ok' }); }); @@ -32,8 +32,8 @@ describe('behaviors', function () { const request = {}, response = { statusCode: 200, body: 'ok' }, logger = Logger.create(), - config = { chaos: {} }, - actualResponse = await behaviors.execute(request, response, [config], logger); + config = { chaos: {} }; + const actualResponse = await behaviors.execute(request, response, [config], logger); assert.deepEqual(actualResponse, { statusCode: 200, body: 'ok' }); }); @@ -43,8 +43,8 @@ describe('behaviors', function () { const request = {}, response = { statusCode: 200, body: 'ok' }, logger = Logger.create(), - config = { chaos: { errorRate: 1, errorStatusCode: 503 } }, - actualResponse = await behaviors.execute(request, response, [config], logger); + config = { chaos: { errorRate: 1, errorStatusCode: 503 } }; + const actualResponse = await behaviors.execute(request, response, [config], logger); assert.strictEqual(actualResponse.statusCode, 503); assert.strictEqual(actualResponse.body, ''); @@ -55,8 +55,8 @@ describe('behaviors', function () { const request = {}, response = { statusCode: 200, body: 'ok' }, logger = Logger.create(), - config = { chaos: { errorRate: 1 } }, - actualResponse = await behaviors.execute(request, response, [config], logger); + config = { chaos: { errorRate: 1 } }; + const actualResponse = await behaviors.execute(request, response, [config], logger); assert.strictEqual(actualResponse.statusCode, 500); assert.strictEqual(actualResponse.body, ''); @@ -67,8 +67,8 @@ describe('behaviors', function () { const request = {}, response = { statusCode: 200, body: 'ok' }, logger = Logger.create(), - config = { chaos: { errorRate: 0.5, errorStatusCode: 503 } }, - actualResponse = await behaviors.execute(request, response, [config], logger); + config = { chaos: { errorRate: 0.5, errorStatusCode: 503 } }; + const actualResponse = await behaviors.execute(request, response, [config], logger); assert.deepEqual(actualResponse, { statusCode: 200, body: 'ok' }); }); @@ -78,8 +78,8 @@ describe('behaviors', function () { const request = {}, response = { statusCode: 200, body: 'ok' }, logger = Logger.create(), - config = { chaos: { errorRate: 0, errorStatusCode: 503 } }, - actualResponse = await behaviors.execute(request, response, [config], logger); + config = { chaos: { errorRate: 0, errorStatusCode: 503 } }; + const actualResponse = await behaviors.execute(request, response, [config], logger); assert.deepEqual(actualResponse, { statusCode: 200, body: 'ok' }); }); @@ -95,9 +95,8 @@ describe('behaviors', function () { logger = Logger.create(), start = Date.now(), config = { chaos: { latencyRate: 1, maxLatencyMs: 100 } }; - - const actualResponse = await behaviors.execute(request, response, [config], logger), - elapsed = Date.now() - start; + const actualResponse = await behaviors.execute(request, response, [config], logger); + const elapsed = Date.now() - start; assert.ok(elapsed >= 90, `expected latency >=90ms, got ${elapsed}ms`); assert.deepEqual(actualResponse, { statusCode: 200, body: 'ok' }); @@ -110,7 +109,6 @@ describe('behaviors', function () { logger = Logger.create(), start = Date.now(), config = { chaos: { latencyRate: 1, maxLatencyMs: 0 } }; - await behaviors.execute(request, response, [config], logger); const elapsed = Date.now() - start; @@ -121,7 +119,7 @@ describe('behaviors', function () { const errors = behaviors.validate([{ chaos: { errorRate: 'oops' } }]); assert.strictEqual(errors.length, 1); assert.strictEqual(errors[0].code, 'bad data'); - assert.ok(errors[0].message.indexOf('errorRate') >= 0, + assert.ok(errors[0].message.includes('errorRate'), `unexpected message: ${errors[0].message}`); }); @@ -129,7 +127,7 @@ describe('behaviors', function () { const errors = behaviors.validate([{ chaos: { maxLatencyMs: -1 } }]); assert.strictEqual(errors.length, 1); assert.strictEqual(errors[0].code, 'bad data'); - assert.ok(errors[0].message.indexOf('maxLatencyMs') >= 0, + assert.ok(errors[0].message.includes('maxLatencyMs'), `unexpected message: ${errors[0].message}`); });