request_shadow/lib.rs
1//! # request-shadow
2//!
3//! Async request mirroring with sampling, divergence detection, and structured
4//! response diffs. The SRE primitive for migrations: send the same request to
5//! the production service AND a candidate, compare the responses, return the
6//! production one to the client while you collect divergence telemetry.
7//!
8//! ## Why a small crate
9//!
10//! Every service-mesh has a knob for this — Linkerd shadowing, Istio mirror,
11//! AWS App Mesh. Those are great when you own the mesh. They're useless when
12//! the migration is in-process (binary library swap, codec change, JSON-vs-
13//! protobuf swap, ORM cutover). This crate gives you the same shape as a
14//! 30-line Tokio task:
15//!
16//! ```
17//! # use std::sync::Arc;
18//! # use request_shadow::{Shadower, ShadowConfig, ResponseRecord, Backend};
19//! # use async_trait::async_trait;
20//! # #[derive(Clone)]
21//! # struct Mock(ResponseRecord);
22//! # #[async_trait]
23//! # impl Backend for Mock {
24//! # async fn call(&self, _input: &[u8]) -> Result<ResponseRecord, request_shadow::ShadowError> {
25//! # Ok(self.0.clone())
26//! # }
27//! # }
28//! # async fn demo() -> Result<(), request_shadow::ShadowError> {
29//! let primary = Arc::new(Mock(ResponseRecord::ok(b"prod".to_vec())));
30//! let shadow = Arc::new(Mock(ResponseRecord::ok(b"prod".to_vec())));
31//! let shadower = Shadower::new(primary, shadow, ShadowConfig::full_sample());
32//!
33//! let outcome = shadower.call(b"hello").await?;
34//! assert!(outcome.primary.ok);
35//! assert!(outcome.divergence.is_none()); // bytes match
36//! # Ok(()) }
37//! ```
38//!
39//! ## Pieces
40//!
41//! - [`Backend`] — the async-trait abstraction the shadower calls. Implement it
42//! over `reqwest::Client` for HTTP, or any in-process call.
43//! - [`ResponseRecord`] — what a backend returns: status code, headers, body.
44//! - [`ShadowConfig`] — sampling rate (sticky over a key hash), timeout for the
45//! shadow leg, fields to ignore in the diff.
46//! - [`Shadower`] — picks whether to mirror based on the sampling key, fires
47//! both calls in a `tokio::join!`, returns a [`ShadowOutcome`].
48//! - [`Divergence`] — structured diff: status / headers / body each get their
49//! own bool + summary.
50//! - [`DivergenceLog`] — bounded ring buffer so the shadower can hand operators
51//! the last N divergences without unbounded memory growth.
52//!
53//! ## Composes with
54//!
55//! - **[reliability-toolkit-rs](https://github.com/mizcausevic-dev/reliability-toolkit-rs)**
56//! — wrap the shadow `Backend` in a [`CircuitBreaker`] so a flaky candidate
57//! never bleeds into the primary path.
58//! - **[slo-budget-tracker](https://github.com/mizcausevic-dev/slo-budget-tracker)**
59//! — record every divergence against an SLO so you can answer "is the
60//! candidate good enough to promote?"
61
62#![warn(missing_docs)]
63#![warn(rust_2018_idioms)]
64#![warn(clippy::pedantic)]
65#![allow(clippy::module_name_repetitions)]
66#![allow(clippy::missing_errors_doc)]
67#![allow(clippy::missing_panics_doc)]
68#![allow(clippy::must_use_candidate)]
69#![allow(clippy::doc_markdown)]
70#![allow(clippy::cast_possible_truncation)]
71#![allow(clippy::cast_sign_loss)]
72
73pub mod backend;
74pub mod config;
75pub mod divergence;
76pub mod error;
77pub mod log;
78pub mod shadower;
79
80pub use backend::{Backend, ResponseRecord};
81pub use config::{IgnoreField, ShadowConfig};
82pub use divergence::Divergence;
83pub use error::ShadowError;
84pub use log::DivergenceLog;
85pub use shadower::{ShadowOutcome, Shadower};