request-shadow 0.1.0

Async request mirroring with sampling, divergence detection, and structured response diffs. SRE bread-and-butter for safe migrations. Composes with reliability-toolkit-rs.
Documentation

request-shadow

CI Rust License: MIT

Async request mirroring with sampling, divergence detection, and structured response diffs. The SRE primitive for migrations: send the same request to the production service AND a candidate, compare the responses, return the production one to the client while you collect divergence telemetry.

use std::sync::Arc;
use request_shadow::{Backend, ResponseRecord, ShadowConfig, Shadower};
# use async_trait::async_trait;
# #[derive(Clone)] struct Mock(ResponseRecord);
# #[async_trait]
# impl Backend for Mock {
#     async fn call(&self, _input: &[u8]) -> Result<ResponseRecord, request_shadow::ShadowError> { Ok(self.0.clone()) }
# }
# async fn demo() -> Result<(), request_shadow::ShadowError> {
let primary  = Arc::new(Mock(ResponseRecord::ok(b"prod".to_vec())));
let shadow   = Arc::new(Mock(ResponseRecord::ok(b"prod".to_vec())));
let shadower = Shadower::new(primary, shadow, ShadowConfig::full_sample());

let outcome = shadower.call(b"hello").await?;
assert!(outcome.primary.ok);
assert!(outcome.divergence.is_none()); // bytes match
# Ok(()) }

Why a small crate

Every service mesh has a knob for traffic shadowing — Linkerd, Istio, AWS App Mesh. They're great when you own the mesh. They're useless when the migration is in-process: binary library swap, codec change, JSON-vs-protobuf swap, ORM cutover.

This crate gives you the same shape as a 30-line Tokio task:

  1. Backend trait — abstracts the call. Implement once per transport.
  2. Shadower — fires both legs concurrently, returns the primary record + an optional divergence.
  3. Divergence — typed diff: status / headers / body each get their own bool + summary.
  4. Sampling — sticky on the input bytes (SHA-256 mod 100). The same input always gets the same yes/no for a given rate.
  5. Timeout for the shadow leg only — never blocks the primary call.

Pieces

Type Purpose
Backend async fn call(&self, input: &[u8]) -> Result<ResponseRecord, _>. Implement over reqwest::Client, your gRPC client, or anything else.
ResponseRecord Backend output: ok, status, sorted headers, opaque body.
ShadowConfig Sampling rate, shadow timeout, list of fields to ignore in the diff.
Shadower The composer. Cheap to clone (both backends are Arc).
ShadowOutcome What Shadower::call returns: primary, optional shadow, optional divergence, plus reason flags.
Divergence status: Option<(u16, u16)>, headers: Option<HeaderDiff>, body: Option<BodyDiff>. Each piece is None when that aspect matches or was ignored.
DivergenceLog Bounded ring buffer of recent divergences for operator inspection.

Sampling

Set sample_rate(N) to mirror N% of requests. Bucketing is sticky over the input bytes:

use request_shadow::ShadowConfig;
let cfg = ShadowConfig::full_sample().sample_rate(10);
assert_eq!(cfg.should_shadow(b"req-key-1"), cfg.should_shadow(b"req-key-1"));

Same key, same answer. Deterministic. No RNG dep.


Composes with


Run the example

cargo run --example inproc

Builds a primary "v1" backend and a "v2" candidate, fires three requests, prints the primary body + the structured divergence each time.


Bench

cargo bench

The bundled bench times Divergence::compare on a 4KB equal body so you can spot regressions in the diff path.


Tests

cargo test --all-targets
cargo test --doc
cargo clippy --all-targets -- -Dwarnings
cargo fmt --all -- --check

CI matrix: stable, beta, 1.86.0 (MSRV). Eleven async tests cover identical responses, body/status/header divergence, ignore-fields, sampling at 0%, sticky sampling, timeout handling, shadow-backend failures, and the divergence log.


License

MIT. See LICENSE.