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..0c12c0a4b --- /dev/null +++ b/test/models/behaviors/chaosTest.js @@ -0,0 +1,152 @@ +'use strict'; + +const assert = require('node: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 } }; + const 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: {} }; + const 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 } }; + const 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 } }; + const 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 } }; + const 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 } }; + const 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); + const 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.includes('errorRate'), + `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.includes('maxLatencyMs'), + `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, []); + }); + }); +});