Extension Points (SPI)
Rift’s storage and observation seams are traits in rift-mock-core. An embedding host implements a trait and injects it through a builder method on ImposterManager; if you don’t, Rift uses its built-in in-memory (or Redis, where applicable) implementation. The built-ins never fail — a custom backend may, and Rift surfaces that failure explicitly (see Backend errors).
All injection is via ImposterManager builder methods:
use std::sync::Arc;
use rift_mock_core::imposter::ImposterManager;
let manager = ImposterManager::new()
.with_flow_store_provider(Arc::new(MyFlowStores))
.with_sequencer(Arc::new(MySequencer))
.with_request_journal(Arc::new(MyJournal))
.with_proxy_store(Arc::new(MyProxyStore))
.with_event_listener(Arc::new(MyListener))
.with_response_decorator(Arc::new(MyDecorator));
Then pass Arc::new(manager) to ServerBuilder::manager(...) (see Embeddable Server).
FlowStoreProvider — custom flow-state backend
Provide a flow-state store per imposter, or return None to defer to the built-ins (in-memory / Redis).
pub trait FlowStoreProvider: Send + Sync {
/// Return a store for this imposter, or `None` to defer to the built-ins.
fn provide(&self, config: &ImposterConfig) -> Option<Arc<dyn FlowStore>>;
}
Inject with .with_flow_store_provider(Arc<dyn FlowStoreProvider>).
ResponseSequencer — custom response cycling
Owns the per-stub cursor that drives multiple-response cycling and repeat (see Behaviors → repeat).
pub trait ResponseSequencer: Send + Sync {
/// Atomically advance and return the response index, honoring per-response repeats.
fn next(&self, key: SequenceKey<'_>, response_count: usize, repeats: &[u32]) -> Result<usize>;
/// Return the upcoming response index without advancing.
fn peek(&self, key: SequenceKey<'_>, response_count: usize, repeats: &[u32]) -> Result<usize>;
/// Reset cursors: one stub's (`Some(stub_key)`) or every cursor on the port (`None`).
/// Also the GC hook — called on stub delete, bulk stub replace, and imposter teardown.
fn reset_scope(&self, port: u16, stub_key: Option<&str>);
}
Inject with .with_sequencer(Arc<dyn ResponseSequencer>).
RequestJournal — custom recorded-requests store
Backs recordRequests, numberOfRequests, and the savedRequests admin surface.
pub trait RequestJournal: Send + Sync {
/// Called for EVERY request (even when body recording is off) — backs `numberOfRequests`.
fn note_request(&self, port: u16);
/// `flow_id` is the request's resolved flow (per the imposter's `flowIdSource`).
fn record(&self, port: u16, flow_id: &str, req: RecordedRequest);
fn read(&self, port: u16) -> JournalRead;
/// Clears entries AND resets the request count. Fallible — a remote store may fail.
fn clear(&self, port: u16) -> anyhow::Result<()>;
fn retain(&self, port: u16, keep: &dyn Fn(&RecordedRequest) -> bool);
/// Clear just one flow's entries. Fallible.
fn clear_flow(&self, port: u16, flow_id: &str) -> anyhow::Result<()>;
fn count(&self, port: u16) -> u64;
}
Note that clear and clear_flow are fallible (anyhow::Result<()>): clearing is a correctness operation whose postcondition (“the data is gone”) a remote backend can fail to guarantee, so the failure propagates rather than being swallowed. Inject with .with_request_journal(Arc<dyn RequestJournal>).
ProxyRecordingStore — custom proxy-recording store
Backs proxy record/replay: claims the right to record a response once per request signature, then stores and looks up recordings.
pub trait ProxyRecordingStore: Send + Sync {
/// First caller per `(port, signature)` wins the right to record once.
/// `Err` = backend unavailable (built-ins never fail).
fn try_claim(&self, port: u16, sig: &RequestSignature) -> Result<ClaimOutcome>;
/// Release a claim after a failed upstream call so the signature is retryable.
fn release_claim(&self, port: u16, sig: &RequestSignature, token: ClaimToken);
fn record(&self, /* port, sig, response, token */) -> Result<()>;
fn lookup(&self, port: u16, sig: &RequestSignature) -> Option<RecordedResponse>;
fn clear(&self, port: u16);
}
Its typed error is ProxyStoreError (ProxyStoreError::Unavailable(String)). Inject with .with_proxy_store(Arc<dyn ProxyRecordingStore>).
ImposterEventListener — observe reconciliation
Get a callback whenever the imposter set changes (startup load, POST /admin/reload, admin CRUD). See Hot Reload for how the incremental diff produces these.
pub enum ImposterEvent {
Created(u16), // port created
Replaced(u16), // port replaced (imposter-level change)
StubsChanged(u16), // in-place stub patch
Deleted(u16), // port deleted
AllDeleted, // every imposter removed
}
pub trait ImposterEventListener: Send + Sync {
fn on_event(&self, event: &ImposterEvent);
}
on_event is called synchronously on the mutating path — keep implementations fast and non-blocking. Inject with .with_event_listener(Arc<dyn ImposterEventListener>).
ResponseDecorator — cross-cutting response headers
A hook to add operational headers to outgoing responses based on the request phase and per-request annotations.
pub trait ResponseDecorator: Send + Sync {
fn decorate(
&self,
phase: ResponsePhase,
req_port: Option<u16>,
annotations: &[(&'static str, String)],
headers: &mut HeaderMap,
);
}
Inject with .with_response_decorator(Arc<dyn ResponseDecorator>).
NoMatchInterceptor — rescue the no-match path
Consulted when a request matched no stub, before the defaultForward / defaultResponse / empty-200 fallthrough (issue #819). Its purpose is a safety net on the data plane: an embedder whose replicated config is momentarily behind can wait a bounded interval and retry the match once, paying nothing on requests that already matched.
use rift_mock_core::extensions::no_match::{
NoMatchContext, NoMatchDirective, NoMatchInterceptor,
};
struct WaitForCatchUp;
impl NoMatchInterceptor for WaitForCatchUp {
fn on_no_match<'a>(
&'a self,
ctx: NoMatchContext<'a>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = NoMatchDirective> + Send + 'a>> {
Box::pin(async move {
if caught_up_within(ctx.port, Duration::from_millis(500)).await {
NoMatchDirective::RetryMatch
} else {
NoMatchDirective::Proceed
}
})
}
}
let manager = ImposterManager::new()
.with_no_match_interceptor(Arc::new(WaitForCatchUp));
Contract:
- Only on a genuine no-match. Never for matched requests, disabled imposters, matcher errors, or the debug path — the hot path pays nothing when nothing missed.
- It fires even when a default IS configured, deliberately. Under replication lag the right stub may be momentarily missing, and forwarding upstream would misdirect the request; rescue outranks forwarding.
Proceedthen falls through exactly as today. - At most one retry per request. A rescued hit is served as a normal match — indistinguishable downstream — and a second miss falls through exactly as
Proceedwould. “Downstream” is precise: the retry re-evaluates predicates, so a predicateinjectscript runs a second time and itsstatemutations andloggeroutput are committed twice; a rescued request can also spend twoscriptEngine.timeoutMsbudgets in matching. - Implementations must be bounded: the request is parked while the future runs.
- Annotations (
extensions::decorate::annotate) are visible wherever aResponseDecoratoris wired — the serve loop. On the/__rift/gateway they are inert, since that path has neither an annotation scope nor a decorator. - Unbound ports are out of scope. A request to a port with no imposter never reaches an imposter handler (no listener, or the gateway
404s first), so there is nothing to hang a hook on. Cover that window with your own readiness gating.
Registering no interceptor leaves behaviour byte-identical, on both the serve loop and the gateway.
Backend errors and annotations
A custom backend signals unavailability by attaching BackendUnavailable to a failed operation’s error (backends wrap with .context(...), and the marker survives the chain):
pub struct BackendUnavailable {
pub feature: &'static str,
pub detail: String,
}
backend_error_response(&anyhow::Error) maps such an error to a structured 503; any other error maps to 500. This is how a down remote store becomes a clean 503 to the API caller rather than a silent fallback. The body carries the standard error envelope, with feature naming which backend failed:
{
"errors": [{
"code": "503",
"type": "backend unavailable",
"message": "flowState: redis connection refused",
"feature": "flowState",
"detail": "redis connection refused"
}],
"error": "backendUnavailable",
"feature": "flowState",
"detail": "redis connection refused"
}
Deprecated: the top-level
error/feature/detailkeys are retained for backward compatibility and will be removed in 0.17.0 (#801). Readerrors[0]instead.
Per-request operational metadata travels through a tokio task-local annotation scope: annotate(key: &'static str, value: String) records a (key, value) that a ResponseDecorator later reads. This is the same mechanism behind the script/behavior error headers — e.g. a script that hits a down flow-store backend records an annotation, and a ctx.state call against that backend is fail-loud: it raises a script error that surfaces to the response rather than silently returning a default (see Scripting → ctx.state and ctx.store).