@gremlin/failure-flags
Advanced tools
Comparing version 0.0.13 to 0.0.14
53
index.js
const { fetchExperiment } = require('./src/fetch.js'); | ||
const effect = require('./src/fault.js'); | ||
const defaultBehavior = effect.delayedException; | ||
const defaultBehavior = effect.delayedResponseOrException; | ||
const ifExperimentActive = async ({name, labels, behavior = defaultBehavior, debug = false}) => { | ||
const ifExperimentActive = async ({name, labels, behavior = defaultBehavior, resultPrototype = null, debug = false}) => { | ||
if(debug) console.log('ifExperimentActive', name, labels); | ||
@@ -20,27 +20,32 @@ if (typeof behavior != 'function') { | ||
if(experiment != null) { | ||
if(debug) console.log('fetched experiment', experiment); | ||
const dice = Math.random(); | ||
if(experiment.rate && | ||
typeof experiment.rate != "string" && | ||
!isNaN(experiment.rate) && | ||
experiment.rate >= 0 && | ||
experiment.rate <= 1 && | ||
dice > experiment.rate) { | ||
if(debug) console.log('probablistically skipped', behaviorError) | ||
return false; | ||
} | ||
if(experiment == null) { | ||
if(debug) console.log('no experiment for', name, labels); | ||
return false; | ||
} | ||
if(debug) console.log('fetched experiment', experiment); | ||
const dice = Math.random(); | ||
if(experiment.rate && | ||
typeof experiment.rate != "string" && | ||
!isNaN(experiment.rate) && | ||
experiment.rate >= 0 && | ||
experiment.rate <= 1 && | ||
dice > experiment.rate) { | ||
if(debug) console.log('probablistically skipped', behaviorError) | ||
return false; | ||
} | ||
try { | ||
await behavior(experiment); | ||
} catch(behaviorError) { | ||
if(debug) console.log('provided behavior error', behaviorError) | ||
throw behaviorError; | ||
} | ||
return true; | ||
try { | ||
if (resultPrototype) { | ||
return await behavior(experiment, resultPrototype); | ||
} else { | ||
await behavior(experiment); | ||
return true; | ||
} | ||
} catch(behaviorError) { | ||
if(debug) console.log('provided behavior error', behaviorError) | ||
throw behaviorError; | ||
} | ||
if(debug) console.log('no experiment for', name, labels); | ||
return false; | ||
} | ||
module.exports = exports = { ifExperimentActive, fetchExperiment, effect }; | ||
module.exports = exports = { ifExperimentActive, fetchExperiment, effect, defaultBehavior }; |
@@ -24,3 +24,3 @@ const failureflags = require('./index.js'); | ||
effect: { | ||
"latency-flat": "6000" | ||
"latency-flat": "10" | ||
} | ||
@@ -37,3 +37,3 @@ }, | ||
effect: { | ||
"latency": "1000", | ||
"latency": "10", | ||
"exception": {} | ||
@@ -51,3 +51,3 @@ } | ||
effect: { | ||
"latency": "1000", | ||
"latency": "10", | ||
"exception": { 'message': 'Custom message' } | ||
@@ -65,5 +65,101 @@ } | ||
effect: { | ||
"latency": "500", | ||
"latency": "50", | ||
} | ||
} | ||
}, | ||
latencySupportsNumber: { | ||
guid: "6884c0df-ed70-4bc8-84c0-dfed703bc8a7", | ||
failureFlagName: "defaultBehavior", | ||
rate: "1", | ||
selector: { | ||
"a":"1", | ||
"b":"2" | ||
}, | ||
effect: { | ||
latency: 11 | ||
} | ||
}, | ||
latencySupportsString: { | ||
guid: "6884c0df-ed70-4bc8-84c0-dfed703bc8a7", | ||
failureFlagName: "defaultBehavior", | ||
rate: "1", | ||
selector: { | ||
"a":"1", | ||
"b":"2" | ||
}, | ||
effect: { | ||
latency: "12" | ||
} | ||
}, | ||
latencySupportsObject: { | ||
guid: "6884c0df-ed70-4bc8-84c0-dfed703bc8a7", | ||
failureFlagName: "defaultBehavior", | ||
rate: "1", | ||
selector: { | ||
"a":"1", | ||
"b":"2" | ||
}, | ||
effect: { | ||
latency: { | ||
ms: 13, | ||
jitter: 0 | ||
} | ||
} | ||
}, | ||
exceptionSupportsString: { | ||
guid: "6884c0df-ed70-4bc8-84c0-dfed703bc8a7", | ||
failureFlagName: "defaultBehavior", | ||
rate: "1", | ||
selector: { | ||
"a":"1", | ||
"b":"2" | ||
}, | ||
effect: { | ||
exception: "custom message" | ||
} | ||
}, | ||
exceptionSupportsExtraProperties: { | ||
guid: "6884c0df-ed70-4bc8-84c0-dfed703bc8a7", | ||
failureFlagName: "defaultBehavior", | ||
rate: "1", | ||
selector: { | ||
"a":"1", | ||
"b":"2" | ||
}, | ||
effect: { | ||
exception: { | ||
extraProperty: "some extra value" | ||
}, | ||
} | ||
}, | ||
exceptionSupportsExtraPropertiesAndMessage: { | ||
guid: "6884c0df-ed70-4bc8-84c0-dfed703bc8a7", | ||
failureFlagName: "defaultBehavior", | ||
rate: "1", | ||
selector: { | ||
"a":"1", | ||
"b":"2" | ||
}, | ||
effect: { | ||
exception: { | ||
message: "custom message", | ||
extraProperty: "some extra value" | ||
}, | ||
} | ||
}, | ||
alteredResponseValue: { | ||
guid: "6884c0df-ed70-4bc8-84c0-dfed703bc8a7", | ||
failureFlagName: "alteredResponseValue", | ||
rate: "1", | ||
selector: { | ||
"a":"1", | ||
"b":"2" | ||
}, | ||
effect: { | ||
response: { | ||
property2: "experiment value", | ||
property3: "experiment originated", | ||
} | ||
} | ||
}, | ||
}; | ||
@@ -83,2 +179,4 @@ | ||
jest.spyOn(global, 'setTimeout'); | ||
test('ifExperimentActive does nothing if callback is not a function', async () => { | ||
@@ -90,2 +188,3 @@ expect(await failureflags.ifExperimentActive({ | ||
debug: false})).toBe(false); | ||
expect(setTimeout).toHaveBeenCalledTimes(0); | ||
}); | ||
@@ -99,2 +198,3 @@ | ||
debug: false})).toBe(false); | ||
expect(setTimeout).toHaveBeenCalledTimes(0); | ||
}); | ||
@@ -107,4 +207,13 @@ | ||
behavior: (t)=>{ console.log('callback called', t); }})).toBe(true); | ||
expect(setTimeout).toHaveBeenCalledTimes(0); | ||
}); | ||
test('around / instead example', async () => { | ||
if (!await failureflags.ifExperimentActive({name:'custom'})) { | ||
expect(true).toBe(false); // always reject if this line is reached. | ||
} | ||
if (await failureflags.ifExperimentActive({name:'defaultBehaviorWithNoException'}) === true) { | ||
expect(setTimeout).toHaveBeenCalledTimes(1); | ||
} | ||
}); | ||
@@ -120,4 +229,6 @@ test('ifExperimentActive default behavior is delayedException with default error message', async () => { | ||
expect(e).not.toBeNull(); | ||
expect(e.message).toBe('Exception injected by Gremlin'); | ||
expect(e.message).toBe('Exception injected by Failure Flags'); | ||
} | ||
expect(setTimeout).toHaveBeenCalledTimes(1); | ||
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 10); | ||
}); | ||
@@ -136,2 +247,3 @@ | ||
} | ||
expect(setTimeout).toHaveBeenCalledTimes(1); | ||
}); | ||
@@ -148,6 +260,124 @@ | ||
} | ||
expect(setTimeout).toHaveBeenCalledTimes(1); | ||
}); | ||
test('latency supports number', async () => { | ||
await failureflags.ifExperimentActive({ | ||
name: 'latencySupportsNumber', | ||
labels: {a:'1',b:'2'}, | ||
behavior: failureflags.effect.latency, | ||
debug: false}); | ||
expect(setTimeout).toHaveBeenCalledTimes(1); | ||
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 11); | ||
}); | ||
test('latency supports string', async () => { | ||
await failureflags.ifExperimentActive({ | ||
name: 'latencySupportsString', | ||
labels: {a:'1',b:'2'}, | ||
behavior: failureflags.effect.latency, | ||
debug: false}); | ||
expect(setTimeout).toHaveBeenCalledTimes(1); | ||
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 12); | ||
}); | ||
test('latency supports object', async () => { | ||
await failureflags.ifExperimentActive({ | ||
name: 'latencySupportsObject', | ||
labels: {a:'1',b:'2'}, | ||
behavior: failureflags.effect.latency, | ||
debug: false}); | ||
expect(setTimeout).toHaveBeenCalledTimes(1); | ||
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 13); | ||
}); | ||
test('exception supports string', async () => { | ||
try { | ||
await failureflags.ifExperimentActive({ | ||
name: 'exceptionSupportsString', | ||
labels: {a:'1',b:'2'}, | ||
behavior: failureflags.effect.exception, // explicitly test the exception effect, not default | ||
debug: false}); | ||
expect(true).toBe(false); // always reject if this line is reached. | ||
} catch(e) { | ||
expect(e).not.toBeNull(); | ||
expect(e.message).toBe('custom message'); | ||
} | ||
expect(setTimeout).toHaveBeenCalledTimes(0); | ||
}); | ||
test('exception supports extra properties', async () => { | ||
try { | ||
await failureflags.ifExperimentActive({ | ||
name: 'exceptionSupportsExtraProperties', | ||
labels: {a:'1',b:'2'}, | ||
behavior: failureflags.effect.exception, // explicitly test the exception effect, not default | ||
debug: false}); | ||
expect(true).toBe(false); // always reject if this line is reached. | ||
} catch(e) { | ||
expect(e).not.toBeNull(); | ||
expect(e).toHaveProperty('message', 'Exception injected by Failure Flags'); | ||
expect(e).toHaveProperty('extraProperty', 'some extra value'); | ||
} | ||
expect(setTimeout).toHaveBeenCalledTimes(0); | ||
}); | ||
test('exception supports extra properties and custom message', async () => { | ||
try { | ||
await failureflags.ifExperimentActive({ | ||
name: 'exceptionSupportsExtraPropertiesAndMessage', | ||
labels: {a:'1',b:'2'}, | ||
behavior: failureflags.effect.exception, // explicitly test the exception effect, not default | ||
debug: false}); | ||
expect(true).toBe(false); // always reject if this line is reached. | ||
} catch(e) { | ||
expect(e).not.toBeNull(); | ||
expect(e).toHaveProperty('message', 'custom message'); | ||
expect(e).toHaveProperty('extraProperty', 'some extra value'); | ||
} | ||
expect(setTimeout).toHaveBeenCalledTimes(0); | ||
}); | ||
test('ifExperimentActive true if prototype response unset', async () => { | ||
try { | ||
const response = await failureflags.ifExperimentActive({ | ||
name: 'defaultBehaviorWithNoException', | ||
labels: {a:'1',b:'2'}, | ||
behavior: failureflags.effect.response, // explicitly test the exception effect, not default | ||
debug: false}); | ||
expect(response).toBe(true); | ||
} catch(e) { | ||
expect(true).toBe(false); // always reject if this line is reached. | ||
} | ||
expect(setTimeout).toHaveBeenCalledTimes(0); | ||
}); | ||
test('ifExperimentActive returns derrived if prototype response set', async () => { | ||
let response; | ||
try { | ||
response = await failureflags.ifExperimentActive({ | ||
name: 'alteredResponseValue', | ||
labels: {a:'1',b:'2'}, | ||
behavior: failureflags.effect.response, // explicitly test the exception effect, not default | ||
resultPrototype: {property1: 'prototype value', property2: 'prototype value'}, | ||
debug: false}); | ||
} catch(e) { | ||
console.dir(e); | ||
expect(true).toBe(false); // always reject if this line is reached. | ||
} | ||
expect(setTimeout).toHaveBeenCalledTimes(0); | ||
expect(response).toHaveProperty('property1', 'prototype value'); | ||
expect(response).toHaveProperty('property2', 'experiment value'); | ||
expect(response).toHaveProperty('property3', 'experiment originated'); | ||
}); | ||
afterEach(() => { | ||
jest.resetAllMocks(); | ||
}); | ||
afterAll(() => { | ||
mockServer.close(); | ||
}); |
{ | ||
"name": "@gremlin/failure-flags", | ||
"version": "0.0.13", | ||
"version": "0.0.14", | ||
"description": "Failure Flags is a node SDK for working with the Gremlin fault injection platform to build application-level chaos experiments and reliability tests. This library works in concert with Gremlin-Lambda, a Lambda extension, or Gremlin-Container a container sidecar agent. This architecture minimizes the impact to your application code, simplifies configuration, and makes adoption painless.", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
142
README.md
@@ -7,2 +7,10 @@ # failure-flags | ||
Take three steps to run an application-level experiment with Failure Flags: | ||
1. Instrument your code with this SDK | ||
2. Configure and deploy your code along side one of the Failure Flag sidecars | ||
3. Run an Experiment with the console, API, or command line | ||
## Instrumenting Your Code | ||
You can get started by adding @gremlin/failure-flags to your package dependencies: | ||
@@ -76,2 +84,3 @@ | ||
console.log('handling the experiment', experiment); | ||
return await gremlin.defaultBehavior(experiment); | ||
}}); | ||
@@ -89,1 +98,134 @@ | ||
``` | ||
### Doing Something Different | ||
Sometimes you need even more manual control. For example, in the event of an experiment you might not want to make some API call or need to rollback some transaction. In most cases the Exception effect can help, but the `ifExperimentActive` function also returns a boolean to indicate if there was an experiment. You can use that to create branches in your code like you would for any feature flag. | ||
```js | ||
... | ||
if (await failureflags.ifExperimentActive({name:'myFlag'})) { | ||
// if there is a running experiment then do this | ||
} else { | ||
// if there is no experiment then do this | ||
} | ||
... | ||
``` | ||
### Pulling the Experiment and Branching Manually | ||
If you want to work with lower-level Experiment data you can use `fetchExperiment(name, labels, debug)` directly. | ||
## Targeting with Selectors | ||
Experiments match specific invocations of a Failure Flag based on its name, and the labels you provide. Experiments define Selectors that the Failure Flags engine uses to determine if an invocation matches. Selectors are simple key to list of values maps. The basic matching logic is every key in a selector must be present in the Failure Flag labels, and at least one of the values in the list for a selector key must match the value in the label. | ||
## Effects and Examples | ||
Once you've instrumented your code and deployed your application with the sidecar you're ready to run an Experiment. None of the work you've done so far describes the Effect during an experiment. You've only marked the spots in code where you want the opportunity to experiment. Gremlin Failure Flags Experiments take an Effect parameter. The Effect parameter is a simple JSON map. That map is provided to the Failure Flags SDK if the application is targeted by a running Experiment. The Failure Flags SDK will process the map according to the default behavior chain or the behaviors you've provided. Today the default chain provides both latency and error Effects. | ||
### Introduce Flat Latency | ||
This Effect will introduce a constant 2000 millisecond delay. | ||
```json | ||
{ "latency": 2000 } | ||
``` | ||
### Introduce Minimum Latency with Some Maximum Jitter | ||
This Effect will introduce between 2000 and 2200 milliseconds of latency where there is a pseudo-random uniform probability of any delay between 2000 and 2200. | ||
```json | ||
{ | ||
"latency": { | ||
"ms": 2000, | ||
"jitter": 200 | ||
} | ||
} | ||
``` | ||
### Throw an Error | ||
This Effect will cause Failure Flags to throw an Error with the provided message. This is useful if your application uses Errors with well-known messages. | ||
```json | ||
{ "exception": "this is a custom message" } | ||
``` | ||
If your app uses custom error types or other error condition metadata then use the object form of exception: | ||
```json | ||
{ | ||
"exception": { | ||
"message": "this is a custom message", | ||
"name": "CustomErrorType", | ||
"someAdditionalProperty": "important metadata" | ||
} | ||
} | ||
``` | ||
### Combining the Two for a "Delayed Exception" | ||
Many common failure modes eventually result in an exception being thrown, but there will be some delay before that happens. Examples include network connection failures, or degradation, or other timeout-based issues. | ||
This Effect Statement will cause a Failure Flag to pause for a full 2 seconds before throwing an exception/error a message, "Custom TCP Timeout Simulation" | ||
```json | ||
{ | ||
"latency": 2000, | ||
"exception": "Custom TCP Timeout Simulation" | ||
} | ||
``` | ||
### Advanced: Changing Response Values | ||
Suppose you want to be able to experiment with mangled responses from your application's dependencies. You can do that with a little extra work. You need to provide a prototype response in your call to `ifExperimentActive`. | ||
```js | ||
... | ||
let response = await failureflags.ifExperimentActive({ | ||
name: 'flagname', // the name of your failure flag | ||
labels: {}, // additional attibutes about this invocation | ||
resultPrototype: {name: 'HTTPResponse'}); // this prototype only sets the name of the response type | ||
... | ||
``` | ||
If the `resultPrototype` property is set then you can use the `response` property in the Effect statement: | ||
```json | ||
{ | ||
"response": { | ||
"statusCode": 404, | ||
"statusMessage": "Not Found" | ||
} | ||
} | ||
``` | ||
Any properties in the `response` object in this map will be copied into a new object created from the prototype you provided. In this example, if the experiment is not running then the `resposne` value will be false, and if it is running the `resposne` variable would contain the following object: | ||
```json | ||
{ | ||
"name": "HTTPResponse", | ||
"statusCode": 404, | ||
"statusMessage": "Not Found" | ||
} | ||
``` | ||
This is exciting because it means that you do not need to mock out whole responses before you know what experiments you'll need to run. | ||
### Advanced: Providing Metadata to Custom Effects | ||
The default effect chain included with the Failure Flags SDK is aware of well-known effect properties including, "latency" and "exception." The user can extend or replace that functionality and use the same properties, or provide their own. For example, suppose a user wants to use a "random jitter" effect that the Standard Chain does not provide. Suppose they wanted to inject a random amount of jitter up to some maximum. They could implement that small extension and make up their own Effect property called, "my-jitter" that specifies that maximum. The resulting Effect Statement would look like: | ||
```json | ||
{ "my-jitter": 500 } | ||
``` | ||
They might also combine this with parts of the default chain: | ||
```json | ||
{ | ||
"latency": 1000, | ||
"my-jitter": 500 | ||
} | ||
``` |
@@ -1,14 +0,41 @@ | ||
const flatLatency = async (experiment) => { | ||
if(experiment && experiment.effect && experiment.effect.latency) | ||
await timeout(parseInt(experiment.effect.latency, 10)); | ||
const latency = async (experiment) => { | ||
if (!experiment.effect.latency) | ||
return; | ||
const latency = experiment.effect.latency; | ||
if(typeof latency === "number") { | ||
await timeout(latency); | ||
} else if(typeof latency === "string") { | ||
await timeout(parseInt(latency, 10)); | ||
} else if(typeof latency === "object") { | ||
let ms = (latency.ms && typeof latency.ms === "number")? latency.ms : 0; | ||
let jitter = (latency.jitter && typeof latency.jitter === "number")? latency.jitter * Math.random() : 0; | ||
await timeout(ms + jitter); | ||
} | ||
} | ||
const exception = (experiment) => { | ||
if(experiment && experiment.effect && experiment.effect.exception) | ||
if(experiment.effect.exception.message) | ||
throw new Error(experiment.effect.exception.message); | ||
else | ||
throw new Error('Exception injected by Gremlin'); | ||
if (!experiment.effect.exception) | ||
return; | ||
const exception = experiment.effect.exception; | ||
if (typeof exception === "string") { | ||
throw new Error(exception); | ||
} else if (typeof exception === "object") { | ||
let toThrow = new Error('Exception injected by Failure Flags'); | ||
Object.assign(toThrow, exception) | ||
throw toThrow; | ||
} | ||
} | ||
const response = async (experiment, prototype) => { | ||
if (!experiment.effect.response || typeof experiment.effect.response !== "object" || !prototype ) | ||
return; | ||
const response = experiment.effect.response; | ||
const res = Object.create(prototype); | ||
Object.assign(res, response); | ||
return res; | ||
} | ||
function timeout(ms) { | ||
@@ -19,6 +46,12 @@ return new Promise(resolve => setTimeout(resolve, ms)); | ||
const delayedException = async (e) => { | ||
await flatLatency(e); | ||
await latency(e); | ||
exception(e); | ||
} | ||
module.exports = exports = { flatLatency, exception, delayedException }; | ||
const delayedResponseOrException = async (e, responsePrototype) => { | ||
await latency(e); | ||
exception(e); | ||
return response(e, responsePrototype); | ||
} | ||
module.exports = exports = { latency, exception, response, delayedException, delayedResponseOrException }; |
27034
8
487
229