Skip to content

Commit

Permalink
feat: add lognormal delay distribution for random delays
Browse files Browse the repository at this point in the history
  • Loading branch information
beltram committed Jun 12, 2023
1 parent 8fff1c8 commit 1f97bb1
Show file tree
Hide file tree
Showing 15 changed files with 203 additions and 43 deletions.
5 changes: 5 additions & 0 deletions book/src/stubs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ You will find here in a single snippet **ALL** the fields/helpers available to y
"response": {
"status": 200, // (required) response status
"fixedDelayMilliseconds": 2000, // delays response by 2 seconds
"delayDistribution": { // a random delay..
"type": "lognormal", // ..with logarithmic distribution
"median": 100, // The 50th percentile of latencies in milliseconds
"sigma": 0.1 // Standard deviation. The larger the value, the longer the tail
},
"jsonBody": { // json response body (automatically adds 'content-type:application/json' header)
"name": "john",
"surnames": [ "jdoe", "johnny" ]
Expand Down
22 changes: 19 additions & 3 deletions book/src/stubs/response.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ You also sometimes have to generate dynamic data or to transform existing one:

## Simulate fault

You can also use [stubr](https://github.com/beltram/stubr) to simulate http server runtime behaviour. And most of the time you'll want to introduce
You can also use [stubr](https://github.com/beltram/stubr) to simulate http server runtime behaviour. And most of the
time you'll want to introduce
latencies
to check how your consuming application reacts to such delays. Currently, the options are quite sparse but should grow !

Expand All @@ -237,13 +238,28 @@ to check how your consuming application reacts to such delays. Currently, the op
"expect": 2,
"response": {
"fixedDelayMilliseconds": 2000
},
"delayDistribution": {
// a random delay with logarithmic distribution
"type": "lognormal",
"median": 100,
// The 50th percentile of latencies in milliseconds
"sigma": 0.1
// Standard deviation. The larger the value, the longer the tail
}
}
```

* `expect` will allow to verify that your unit test has not called the given stub more than N times. Turn it on like
this `stubr::Stubr::start_with(stubr::Config { verify: true, ..Default::default() })`
or `#[stubr::mock(verify = true)]` with the attribute macro
* `fixedDelayMilliseconds` a delay (in milliseconds) added everytime this stub is matched. If you are using [stubr](https://github.com/beltram/stubr)
* `fixedDelayMilliseconds` a delay (in milliseconds) added everytime this stub is matched. If you are
using [stubr](https://github.com/beltram/stubr)
standalone through the [cli](../cli.md), this value can be either superseded by `--delay` or complemented
by `--latency`
by `--latency`
* `delayDistribution` for random delays (always in milliseconds), use `type` to choose the one
* `lognormal` is a pretty good approximation of long tailed latencies centered on the 50th
percentile. [Try different values](https://www.wolframalpha.com/input/?i=lognormaldistribution%28log%2890%29%2C+0.4%29)
to find a good approximation.
* `median`: the 50th percentile of latencies in milliseconds
* `sigma`: standard deviation. The larger the value, the longer the tail.
1 change: 1 addition & 0 deletions lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ rand = "0.8"
regex-syntax = { version = "0.6", default-features = false }
rand_regex = { version = "0.15", default-features = false }
thiserror = "1.0"
jandom = "0.3"

isahc = { version = "1.7", optional = true, default-features = false }
reqwest = { version = "0.11", optional = true, default-features = false }
Expand Down
8 changes: 4 additions & 4 deletions lib/src/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ impl JsonStub {
use crate::model::response::ResponseAppender as _;

let mut template = ResponseTemplate::new(resp.status());
template = crate::model::response::default::WiremockIsoResponse(self.uuid.as_deref()).add(template);
template = crate::model::response::delay::Delay(resp.fixed_delay_milliseconds, config).add(template);
template = response::default::WiremockIsoResponse(self.uuid.as_deref()).add(template);
template = response::delay::Delay(resp.fixed_delay_milliseconds, &resp.delay_distribution, config).add(template);
if resp.requires_response_templating() {
resp.headers.register_template();
resp.body.register_template();
Expand All @@ -150,8 +150,8 @@ impl JsonStub {

let resp = &ResponseStub::default();
let mut template = ResponseTemplate::new(resp.status());
template = crate::model::response::default::WiremockIsoResponse(self.uuid.as_deref()).add(template);
template = crate::model::response::delay::Delay(resp.fixed_delay_milliseconds, config).add(template);
template = response::default::WiremockIsoResponse(self.uuid.as_deref()).add(template);
template = response::delay::Delay(resp.fixed_delay_milliseconds, &resp.delay_distribution, config).add(template);
StubTemplate {
template,
requires_templating: false,
Expand Down
17 changes: 14 additions & 3 deletions lib/src/model/response/delay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,32 @@ use crate::Config;

use super::ResponseAppender;

pub struct Delay<'a>(pub Option<u64>, pub &'a Config);
/// See [https://wiremock.org/docs/simulating-faults/#per-stub-fixed-delays]
pub struct Delay<'a>(pub Option<u64>, pub &'a Option<RandomDelay>, pub &'a Config);

impl ResponseAppender for Delay<'_> {
fn add(&self, mut resp: ResponseTemplate) -> ResponseTemplate {
if let Some(global_delay) = self.1.global_delay {
if let Some(global_delay) = self.2.global_delay {
resp = resp.set_delay(Duration::from_millis(global_delay))
} else if let Some(latency) = self.1.latency {
} else if let Some(latency) = self.2.latency {
if let Some(delay) = self.0 {
resp = resp.set_delay(Duration::from_millis(latency + delay))
} else {
resp = resp.set_delay(Duration::from_millis(latency))
}
} else if let Some(delay) = self.0 {
resp = resp.set_delay(Duration::from_millis(delay))
} else if let Some(RandomDelay::Lognormal { median, sigma }) = self.1 {
resp = resp.set_lognormal_delay(*median, *sigma)
}
resp
}
}

/// See [https://wiremock.org/docs/simulating-faults/#per-stub-random-delays]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type")]
pub enum RandomDelay {
#[serde(rename = "lognormal")]
Lognormal { median: u64, sigma: f64 },
}
4 changes: 4 additions & 0 deletions lib/src/model/response/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::hash::{Hash, Hasher};

use crate::wiremock::ResponseTemplate;

use crate::model::response::delay::RandomDelay;
use body::BodyStub;
use headers::HttpRespHeadersStub;

Expand All @@ -21,6 +22,9 @@ pub struct ResponseStub {
/// delay in milliseconds to apply to the response
#[serde(skip_serializing)]
pub fixed_delay_milliseconds: Option<u64>,
/// random delay accepting different distributions
#[serde(skip_serializing)]
pub delay_distribution: Option<RandomDelay>,
/// HTTP response body
#[serde(flatten)]
pub body: BodyStub,
Expand Down
3 changes: 2 additions & 1 deletion lib/src/record/mapping/resp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ impl From<RecordInput<'_>> for ResponseStub {
fn from((ex, cfg): RecordInput) -> Self {
Self {
status: Some(ex.resp().status().into()),
fixed_delay_milliseconds: None,
body: BodyStub::from(&mut *ex),
headers: HttpRespHeadersStub::from((&mut *ex, cfg)),
transformers: vec![],
fixed_delay_milliseconds: None,
delay_distribution: None,
}
}
}
Expand Down
39 changes: 39 additions & 0 deletions lib/src/wiremock/delay.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/// A distribution of a random delay
#[derive(Debug, Clone)]
pub struct LognormalDelay {
pub median: u64,
pub sigma: f64,
}

impl LognormalDelay {
/// see [https://github.com/wiremock/wiremock/blob/60e9e858068548786af4a1a434b52fd1376c4d43/src/main/java/com/github/tomakehurst/wiremock/http/LogNormal.java#L52]
pub fn new_sample(&self) -> core::time::Duration {
let seed = rand::random::<i64>();
let rand = jandom::Random::new(seed).next_gaussian();
let milli = ((rand * self.sigma).exp() * self.median as f64).round() as u64;
core::time::Duration::from_millis(milli)
}
}

#[cfg(test)]
pub mod tests {
use super::*;

#[test]
fn lognormal_should_return_expected_mean() {
const ROUNDS: usize = 10_000;
const DELTA: f64 = 5.0;
const EXPECTED: f64 = 97.1115;
const LOWER: f64 = EXPECTED - DELTA;
const UPPER: f64 = EXPECTED + DELTA;

let lognormal = LognormalDelay { median: 90, sigma: 0.39 };
let mut sum = 0.0;
for _ in 0..ROUNDS {
let sample = lognormal.new_sample().as_millis() as f64;
sum += sample;
}
let mean = sum / (ROUNDS as f64);
assert!((mean > LOWER) && (mean < UPPER));
}
}
3 changes: 2 additions & 1 deletion lib/src/wiremock/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ impl MountedMockSet {
}
}
if let Some(response_template) = response_template {
let delay = response_template.delay().map(|d| Delay::new(d.to_owned()));
let delay = response_template.delay().map(|d| Delay::new(d.into_owned()));
(response_template.generate_grpc_response(), delay)
} else {
let default_resp = tonic::codegen::http::Response::builder()
Expand All @@ -64,6 +64,7 @@ impl ResponseTemplate {
mime: None,
body: None,
delay: None,
lognormal_delay: None,
}
}

Expand Down
2 changes: 1 addition & 1 deletion lib/src/wiremock/mock_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ impl MountedMockSet {
}
}
if let Some(response_template) = response_template {
let delay = response_template.delay().map(|d| Delay::new(d.to_owned()));
let delay = response_template.delay().map(|d| Delay::new(d.into_owned()));
(response_template.generate_response(), delay)
} else {
debug!("Got unexpected request:\n{}", request);
Expand Down
1 change: 1 addition & 0 deletions lib/src/wiremock/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Forking the excellent [wiremock](https://crates.io/crates/wiremock) since this crates will
//! diverge a lot from it: gRPC support, focus more on standalone mode and using it in Docker
//! meaning no panic allowed
mod delay;
#[cfg(feature = "grpc")]
pub mod grpc;
pub mod http;
Expand Down
17 changes: 15 additions & 2 deletions lib/src/wiremock/response_template.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::wiremock::delay::LognormalDelay;
use http_types::headers::{HeaderName, HeaderValue};
use http_types::{Response, StatusCode};
use serde::Serialize;
Expand All @@ -19,6 +20,7 @@ pub struct ResponseTemplate {
pub(crate) headers: HashMap<HeaderName, Vec<HeaderValue>>,
pub(crate) body: Option<Vec<u8>>,
pub(crate) delay: Option<Duration>,
pub(crate) lognormal_delay: Option<LognormalDelay>,
}

// `wiremock` is a crate meant for testing - failures are most likely not handled/temporary mistakes.
Expand Down Expand Up @@ -255,7 +257,12 @@ impl ResponseTemplate {
/// [`MockServer`]: crate::mock_server::MockServer
pub fn set_delay(mut self, delay: Duration) -> Self {
self.delay = Some(delay);
self
}

/// see [https://wiremock.org/docs/simulating-faults/#lognormal-delay]
pub fn set_lognormal_delay(mut self, median: u64, sigma: f64) -> Self {
self.lognormal_delay = Some(LognormalDelay { median, sigma });
self
}

Expand All @@ -282,7 +289,13 @@ impl ResponseTemplate {
}

/// Retrieve the response delay.
pub(crate) fn delay(&self) -> &Option<Duration> {
&self.delay
pub(crate) fn delay(&self) -> Option<std::borrow::Cow<Duration>> {
if let Some(delay) = self.delay.as_ref() {
Some(std::borrow::Cow::Borrowed(delay))
} else {
self.lognormal_delay
.as_ref()
.map(|lognormal_delay| std::borrow::Cow::Owned(lognormal_delay.new_sample()))
}
}
}
85 changes: 57 additions & 28 deletions lib/tests/resp/delay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,64 @@ use asserhttp::*;
use async_std::{io, task};
use surf::get;

#[async_std::test]
#[stubr::mock("resp/delay/5-seconds.json")]
async fn should_timeout_with_delay_of_5_seconds() {
let timeout = Duration::from_secs(1);
let timeout = task::block_on(io::timeout(timeout, async {
get(stubr.uri()).await.expect_status_ok();
Ok(())
}));
assert!(timeout.is_err());
}
mod fixed {
use super::*;

#[async_std::test]
#[stubr::mock("resp/delay/5-seconds.json")]
async fn should_timeout_with_delay_of_5_seconds() {
let timeout = Duration::from_secs(1);
let timeout = task::block_on(io::timeout(timeout, async {
get(stubr.uri()).await.expect_status_ok();
Ok(())
}));
assert!(timeout.is_err());
}

#[async_std::test]
#[stubr::mock("resp/delay/5-seconds.json")]
async fn should_not_timeout_with_delay_of_5_seconds() {
let timeout = Duration::from_secs(30);
let timeout = task::block_on(io::timeout(timeout, async {
get(stubr.uri()).await.expect_status_ok();
Ok(())
}));
assert!(timeout.is_ok());
}

#[async_std::test]
#[stubr::mock("resp/delay/5-seconds.json")]
async fn should_not_timeout_with_delay_of_5_seconds() {
let timeout = Duration::from_secs(30);
let timeout = task::block_on(io::timeout(timeout, async {
get(stubr.uri()).await.expect_status_ok();
Ok(())
}));
assert!(timeout.is_ok());
#[async_std::test]
#[stubr::mock("resp/delay/no-delay.json")]
async fn should_not_timeout_with_no_delay() {
let timeout = Duration::from_millis(100);
let timeout = task::block_on(io::timeout(timeout, async {
get(stubr.uri()).await.expect_status_ok();
Ok(())
}));
assert!(timeout.is_ok());
}
}

#[async_std::test]
#[stubr::mock("resp/delay/no-delay.json")]
async fn should_not_timeout_with_no_delay() {
let timeout = Duration::from_millis(100);
let timeout = task::block_on(io::timeout(timeout, async {
get(stubr.uri()).await.expect_status_ok();
Ok(())
}));
assert!(timeout.is_ok());
mod lognormal {
use super::*;

// see [https://www.wolframalpha.com/input?i=lognormaldistribution%28log%285000%29%2C+1.0%29]
#[async_std::test]
#[stubr::mock("resp/delay/lognormal-m100-s01.json")]
async fn should_timeout_with_rand_delay_of_100ms_with_01_sigma() {
const ROUNDS: usize = 30;
const DELTA: f64 = 15.0;
const EXPECTED: f64 = 100.0;
const LOWER: f64 = EXPECTED - DELTA;
const UPPER: f64 = EXPECTED + DELTA;

let mut sum = 0.0;
for _ in 0..ROUNDS {
let begin = std::time::Instant::now();
get(stubr.uri()).await.expect_status_ok();
let delta = std::time::Instant::now() - begin;
sum += delta.as_millis() as f64;
}
let mean = sum / (ROUNDS as f64);
assert!((mean > LOWER) && (mean < UPPER));
}
}
13 changes: 13 additions & 0 deletions lib/tests/stubs/resp/delay/lognormal-m100-s01.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET"
},
"response": {
"status": 200,
"delayDistribution": {
"type": "lognormal",
"median": 100,
"sigma": 0.1
}
}
}
Loading

0 comments on commit 1f97bb1

Please sign in to comment.