Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion src/models/behaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
};

Expand Down Expand Up @@ -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<Object>}
*/
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
Expand All @@ -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);

Expand Down
15 changes: 15 additions & 0 deletions src/views/docs/api/behaviors.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ the following behaviors:</p>
line flag must be set to support this behavior.</p>
</td>
</tr>
<tr>
<td><code>chaos</code></td>
<td>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.</td>
</tr>
</table>

<p>Multiple behaviors can be added to a response, and they will be executed in array order. While
Expand Down Expand Up @@ -136,6 +142,15 @@ response transformation.</p>
<%- include('behaviors/shellTransform') -%>
</section>
</div>
<div>
<a class='section-toggler'
id='behavior-chaos' name='behavior-chaos' href='#behavior-chaos'>
chaos
</a>
<section>
<%- include('behaviors/chaos') -%>
</section>
</div>
</section>

<%- include('../../_footer') -%>
65 changes: 65 additions & 0 deletions src/views/docs/api/behaviors/chaos.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<table>
<tr>
<th>Parameter</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>errorRate</code></td>
<td>A number between 0 and 1</td>
<td>The probability that the response will be replaced with an error.
Defaults to 0 (no error injection).</td>
</tr>
<tr>
<td><code>errorStatusCode</code></td>
<td>A non-negative integer</td>
<td>The status code to return when injecting an error. Defaults to 500.</td>
</tr>
<tr>
<td><code>latencyRate</code></td>
<td>A number between 0 and 1</td>
<td>The probability that random latency will be added to the response.
Defaults to 0 (no latency injection).</td>
</tr>
<tr>
<td><code>maxLatencyMs</code></td>
<td>A non-negative integer</td>
<td>The maximum latency in milliseconds. When triggered, the injected latency
is a uniformly random value between 0 and this maximum.</td>
</tr>
</table>

<p>The <code>chaos</code> 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.</p>

<p>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:</p>

<pre><code>{
"responses": [
{
"is": { "statusCode": 200, "body": "ok" },
"behaviors": [
{
"chaos": {
"errorRate": 0.1,
"errorStatusCode": 503,
"latencyRate": 0.05,
"maxLatencyMs": 1000
}
}
]
}
]
}</code></pre>

<p>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.</p>

<p>If both <code>errorRate</code> and <code>latencyRate</code> trigger on the same
request, latency is added first and then the response is replaced with the error.</p>
152 changes: 152 additions & 0 deletions test/models/behaviors/chaosTest.js
Original file line number Diff line number Diff line change
@@ -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);

Check failure on line 25 in test/models/behaviors/chaosTest.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` of a non-Promise (non-"Thenable") value.

See more on https://sonarcloud.io/project/issues?id=mountebank-testing_mountebank&issues=AZ4dxc6PE_rx_jr6qxtv&open=AZ4dxc6PE_rx_jr6qxtv&pullRequest=919

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);

Check failure on line 36 in test/models/behaviors/chaosTest.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` of a non-Promise (non-"Thenable") value.

See more on https://sonarcloud.io/project/issues?id=mountebank-testing_mountebank&issues=AZ4dxc6PE_rx_jr6qxtw&open=AZ4dxc6PE_rx_jr6qxtw&pullRequest=919

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);

Check failure on line 47 in test/models/behaviors/chaosTest.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` of a non-Promise (non-"Thenable") value.

See more on https://sonarcloud.io/project/issues?id=mountebank-testing_mountebank&issues=AZ4dxc6PE_rx_jr6qxtx&open=AZ4dxc6PE_rx_jr6qxtx&pullRequest=919

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);

Check failure on line 59 in test/models/behaviors/chaosTest.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` of a non-Promise (non-"Thenable") value.

See more on https://sonarcloud.io/project/issues?id=mountebank-testing_mountebank&issues=AZ4dxc6PE_rx_jr6qxty&open=AZ4dxc6PE_rx_jr6qxty&pullRequest=919

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);

Check failure on line 71 in test/models/behaviors/chaosTest.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` of a non-Promise (non-"Thenable") value.

See more on https://sonarcloud.io/project/issues?id=mountebank-testing_mountebank&issues=AZ4dxc6PE_rx_jr6qxtz&open=AZ4dxc6PE_rx_jr6qxtz&pullRequest=919

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);

Check failure on line 82 in test/models/behaviors/chaosTest.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` of a non-Promise (non-"Thenable") value.

See more on https://sonarcloud.io/project/issues?id=mountebank-testing_mountebank&issues=AZ4dxc6PE_rx_jr6qxt0&open=AZ4dxc6PE_rx_jr6qxt0&pullRequest=919

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);

Check failure on line 98 in test/models/behaviors/chaosTest.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` of a non-Promise (non-"Thenable") value.

See more on https://sonarcloud.io/project/issues?id=mountebank-testing_mountebank&issues=AZ4dyLVancFtX766ubUv&open=AZ4dyLVancFtX766ubUv&pullRequest=919
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);

Check failure on line 112 in test/models/behaviors/chaosTest.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` of a non-Promise (non-"Thenable") value.

See more on https://sonarcloud.io/project/issues?id=mountebank-testing_mountebank&issues=AZ4dxc6PE_rx_jr6qxt2&open=AZ4dxc6PE_rx_jr6qxt2&pullRequest=919
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, []);
});
});
});