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))
.with_no_match_interceptor(Arc::new(MyRescue))
.with_exchange_inspector_provider(Arc::new(MyInspectors));
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>).
A provider overrides the imposter’s own _rift.flowState selection, and it cannot report a failure — provide returns Option, so declining falls through to the built-ins. When you want a store the config selects by name, and misconfiguration to fail loudly, use FlowStoreBackendFactory below instead.
FlowStoreBackendFactory — a named flow-state backend
Adds a _rift.flowState.backend name the config can select, with an error channel. This is how the Redis backend ships: it lives in the separate rift-store-redis crate, so rift-mock-core itself carries no redis/r2d2 dependency under any feature combination.
pub trait FlowStoreBackendFactory: Send + Sync {
/// The `_rift.flowState.backend` string this factory serves, e.g. "redis".
fn name(&self) -> &'static str;
/// Build a store for this imposter's flowState block. An `Err` fails imposter creation.
fn build(&self, config: &RiftFlowStateConfig) -> anyhow::Result<Arc<dyn FlowStore>>;
}
Register with .with_flow_store_backends(FlowStoreBackends):
use rift_mock_core::extensions::flow_state::FlowStoreBackends;
let backends = FlowStoreBackends::new().with(Arc::new(MyBackend));
let manager = ImposterManager::new().with_flow_store_backends(backends);
The rift binary and the C-ABI register their shipped backends automatically — with the default redis-backend feature that means "redis", so _rift.flowState.backend: "redis" works out of the box. rift_http_proxy::default_flow_store_backends() returns that set if you are assembling a manager yourself and want the same vocabulary.
Choosing between the two seams:
FlowStoreProvider | FlowStoreBackendFactory | |
|---|---|---|
| Selected by | the embedder, for every imposter | the imposter’s flowState.backend name |
| Precedence | overrides _rift.flowState | only consulted when the config names it |
| On failure | can only decline (None) → falls through | returns Err → imposter creation fails with 400 |
A backend name that nothing registered is a config error, never a silent downgrade to a no-op store: creation fails with an error listing the names this build does serve.
Implementing FlowStore
Either seam hands back an Arc<dyn FlowStore> (rift_mock_core::flow_state::FlowStore). Required: get, set, exists, delete, increment, set_ttl. Defaulted, so an older implementation keeps compiling: is_blocking, increment_by, set_key_ttl, clear_flow, compare_and_set (a non-atomic get-then-set; a real backend should override it), flow_ids, entry_count.
is_blocking()(defaultfalse): returntrueif calls do network or disk I/O. The request path then runs flow-store calls onspawn_blockingso a slow backend cannot stall a tokio worker. Annotations your store records there reach theResponseDecorator(#987 fixed them being dropped on that thread).flow_ids()/entry_count(flow_id)(issue #962): list the flow ids that have live state, and count the live keys under one. Both returnResult<Option<_>>, and the default isOk(None), meaning “this store cannot enumerate”. That is different fromSome(vec![])/Some(0), which mean “it can, and there are none”. The built-in in-memory store enumerates. The Redis store deliberately returnsNone, because an admin screen should not trigger aSCANover a shared keyspace. Keep the two answers distinct, so a UI shows “unsupported” rather than “empty”.
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;
// Defaulted — override for richer behaviour:
fn read_filtered(&self, port: u16, keep: &dyn Fn(&RecordedRequest) -> bool) -> JournalRead;
/// Stable per-port indices (the `since=` cursor). `None` = unsupported (the default).
fn read_since(&self, port: u16, since: Option<u64>,
keep: &dyn Fn(&RecordedRequest) -> bool) -> Option<JournalReadSince>;
/// Default calls `record` and returns `None`. Never point `record` back at it without overriding it.
fn record_indexed(&self, port: u16, flow_id: &str, req: RecordedRequest) -> Option<u64>;
/// Second writes after the entry exists (no-ops by default).
fn attach_match(&self, port: u16, index: u64, outcome: MatchOutcome);
fn attach_response(&self, port: u16, index: u64, status: u16, latency_ms: u64);
}
The attach_* hooks fill in matchOutcome, and since issue #940 also status and latencyMs, on an entry recorded earlier. A backend without stable indices cannot address an entry, so it never carries these fields. That is not an error. RecordedRequest also has a node: Option<String> that the engine never sets: a journal spanning several nodes stamps it in its own record.
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 the engine will not settle, so the signature is retryable.
fn release_claim(&self, port: u16, sig: &RequestSignature, token: ClaimToken);
fn record(&self, /* port, sig, response, token */) -> Result<()>;
/// Settle the claim once the generated stub exists, before it is published.
/// Defaults to `record`, so implementing it is optional.
fn complete(&self, /* port, sig, token, response */
publication: &StubPublication<'_>) -> Result<()> { /* -> record */ }
/// `true` = this store publishes generated stubs itself; the engine then inserts none.
fn publishes_stubs(&self) -> bool { false }
fn lookup(&self, port: u16, sig: &RequestSignature) -> Option<RecordedResponse>;
fn clear(&self, port: u16);
}
Its typed error is ProxyStoreError, #[non_exhaustive]. Inject with .with_proxy_store(Arc<dyn ProxyRecordingStore>).
| Variant | Engine response |
|---|---|
Unavailable(String) | Degrade: forward upstream without recording. Use it when the store only helps persistence and the engine still enforces exactly-once itself. |
Refused(BackendUnavailable) (issue #990) | Fail the request without calling the upstream. Use it when the store is the exactly-once arbiter (shared or clustered) and could not decide. Forwarding would let every request during the outage reach the upstream. Both the stub proxy and defaultForward answer 503 through backend_error_response (see Backend errors). |
ClaimOutcome::InFlight is unaffected: a claim was serialized, so that request still forwards.
release_claim can be called from a destructor. The engine holds a won claim in a guard that releases it when the request is dropped — a client that disconnected mid-request, an imposter stopped while a request was in flight, a panic — as well as after a failed forward or a failed behavior (issue #1193). So release_claim runs synchronously on the dropping thread, possibly while the runtime is shutting down: it must not block for long, must not assume an async context, and must ignore a token whose claim is already settled or re-taken (the built-in store does). A store that releases over the network should hand the release off rather than wait for it.
Publishing stubs from the store
record is called before the engine has generated the stub the recording will be replayed from, which is fine for an in-process store but not for one that must publish that stub somewhere durable or replicated: it would have to commit “this signature is Recorded” against a stub that does not exist yet, and a publication that then failed would leave the signature permanently recorded with nothing to replay.
complete is the seam for that. It is called instead of record whenever a stub was generated (predicateGenerators, addWaitBehavior or addDecorateBehavior configured, and generation succeeded), and always before the stub is published — so a store can make its own commit conditional on its publication ack. Returning Err releases the claim, leaving the signature retryable; the client still receives the upstream response, because the upstream call succeeded and only recording failed. When no stub is generated — nothing configured to generate one, or predicate generation failed — there is nothing to publish and the engine calls record as before.
publishes_stubs() == true additionally makes the engine skip its own in-process stub insertion, so the store is the sole publisher. It must then honour the position the engine resolved, which arrives alongside the stub:
pub struct StubPublication<'a> {
pub stub: &'a Stub, // the generated stub, exactly as the engine would insert it
pub placement: StubPlacement, // where it belongs relative to the proxy stub
pub proxy_to: &'a str, // `proxy.to` of that proxy stub — the anchor
}
pub enum StubPlacement {
/// proxyOnce: insert BEFORE the proxy stub, so the recording matches first next time.
BeforeProxy,
/// proxyAlways: place AFTER the proxy stub (so the proxy keeps recording), merging responses
/// into an existing stub with structurally equal, non-empty predicates rather than appending
/// a duplicate.
AfterProxyMerging,
}
Both additions are defaulted, so a store written against the pre-complete trait keeps compiling and behaving identically. StubPublication and StubPlacement are #[non_exhaustive] — match the placement with a _ arm and treat an unknown one as unpublishable rather than guessing a position.
Two consequences worth knowing before you set publishes_stubs() == true:
- Skipping is unconditional. When no claim was won — a concurrent
proxyOnceloser, ortry_claimreporting the backend unavailable — there is nocompletecall and no local insertion, so that request’s stub is published nowhere. The engine will not quietly keep a private copy your store’s peers do not have. - The claim is held across predicate generation. Negligible for the ordinary generators, but a
predicateGenerators.injectscript runs under the script timeout (5s by default), and a concurrentproxyOncerequest arriving inside that window seesInFlightrather thanAlreadyRecorded, so it proxies upstream instead of replaying. The signature is still recorded exactly once.
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 struct EventContext {
pub principal: Option<String>, // who caused the change
}
pub trait ImposterEventListener: Send + Sync {
fn on_event(&self, event: &ImposterEvent, ctx: &EventContext);
}
on_event is called synchronously on the mutating path — keep implementations fast and non-blocking. Inject with .with_event_listener(Arc<dyn ImposterEventListener>).
Attribution — who changed it
An event says what changed; ctx.principal says who (issue #855). It is populated from AuthzDecision::Allow { principal } when an AdminAuthorizer is installed, which is what makes these events usable as an audit trail instead of something you have to correlate against request logs out of band.
principal is None whenever there is genuinely nobody to name, and it is never guessed:
| Situation | ctx.principal |
|---|---|
| No authorizer installed | None — the seam stays inert by default |
Authorizer returned AuthzDecision::allow() | None — allowed, but nobody identified |
Authorizer returned Allow { principal: Some(p) } | Some(p) |
POST /admin/reload (re-reads --configfile) | Some(p) — it is an admin request like any other |
The startup config read, or your own direct ImposterManager call | None — no request behind the change |
Note the third and fourth rows together: what decides attribution is whether a request caused the change, not where the config came from. The same --configfile yields None when read at boot and Some(p) when re-read through POST /admin/reload.
Attribution rides on the listener signature, not on ImposterEvent. The enum is unchanged, so an existing match over it keeps compiling untouched. EventContext is #[non_exhaustive] so a later attribution field is not another break — construct one in your own tests from EventContext::default() and assign fields.
Three bounds worth knowing:
AllDeletedcarries no port, so a fleet-wide delete records the actor but no per-resource target. That is the shape, not a defect.- The principal travels as a
tokiotask-local scoped to the admin request. A mutation you drive from atokio::spawned task of your own does not inherit that scope and will reportNone. - Only this listener is attributed. The admin SSE bus (
GET /events) publishes the same lifecycle changes with no principal, so an audit collector pointed at/eventsgets what but not who. If you need attribution, consume it here.
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.
ExchangeInspector — policy on live exchanges
A synchronous hook pair on an imposter’s live traffic (issue #966). It can reject a request before matching and replace a response before it is written. Use it for request linting, contract validation, compliance capture or a chaos veto. It sits where no other seam does: NoMatchInterceptor fires only on a miss, and ResponseDecorator may only add headers.
use rift_mock_core::extensions::exchange_inspector::{
ExchangeInspector, ExchangeInspectorProvider, InspectRequest, InspectResponse, InspectVerdict,
};
pub trait ExchangeInspector: Send + Sync {
/// After body collection and journaling, before stub matching.
fn inspect_request(&self, req: &InspectRequest<'_>) -> InspectVerdict;
/// After the response is built, before it is written and decorated.
fn inspect_response(&self, req: &InspectRequest<'_>, resp: &InspectResponse<'_>) -> InspectVerdict;
}
pub trait ExchangeInspectorProvider: Send + Sync {
/// Consulted once per imposter, at creation. `None` = no hooks for that imposter.
fn provide(&self, config: &ImposterConfig) -> Option<Arc<dyn ExchangeInspector>>;
}
pub enum InspectVerdict {
Proceed,
Reject { status: u16, content_type: String, body: Bytes },
}
Inject with .with_exchange_inspector_provider(Arc<dyn ExchangeInspectorProvider>).
InspectRequestborrowsport,method,path,query(without the?),headers(every value),body(text, or base64 whenmodeis binary) andmode.InspectResponseborrowsstatus,headersandbodybytes.- Request-side rejection happens before matching, so a rejected request never advances a response cycler, a scenario or a match count. It is still journaled, with the rejection’s status.
- The response hook runs on every path: the serve loop, the
/__rift/gateway and in-process dispatch. It runs before the decorator and CORS. It is not called for a response the request hook produced. - Early exits see neither hook: a disabled imposter, a CORS preflight, a
413, a body-read error. - Inert by default. With no provider, or a provider returning
None, the cost is oneis_nonecheck per phase. The hooks are synchronous on purpose: keep I/O off them.
Detecting a TCP fault in-process
A program that calls handle_imposter_request directly receives a placeholder carrier response when a stub injects a TCP fault, where a socket client would see the connection aborted. Classify it with rift_mock_core::tcp_fault_carrier(&response), which returns the canonical fault name or None. To branch per fault, read the #[non_exhaustive] TcpFaultKind extension (issue #984). Both are also re-exported from rift_http_proxy. See Fault injection → Detecting a fault in-process. Do not classify on the x-rift-fault header.
Observing front-door route dispatches
RouteObserver (rift_http_proxy::front_door) is called once for each request a route claims, with the route id. It is meant for a per-route hit counter (issue #961). Pass it to bind_front_door_with_observer(addr, manager, routes, Some(observer)). bind_front_door is the same call without an observer. See Front door → Observing dispatches.
AdminAuthorizer — per-request admin authorization
The built-in --api-key gate yields access, not an identity: every caller that presents the key is equivalent. AdminAuthorizer lets an embedder decide per request, with the route already parsed.
use rift_mock_core::extensions::authz::{
AdminAuthorizer, AuthzDecision, AuthzRequest, actions,
};
struct TenantAuthorizer;
impl AdminAuthorizer for TenantAuthorizer {
fn authorize(&self, req: AuthzRequest<'_>) -> AuthzDecision {
let principal = match req.credential.and_then(lookup_principal) {
Some(p) => p,
None => return AuthzDecision::Deny { reason: "unknown principal" },
};
match req.action {
actions::IMPOSTER_DELETE if !principal.may_delete => {
AuthzDecision::Deny { reason: "delete not permitted" }
}
_ => AuthzDecision::Allow { principal: Some(principal.name) },
}
}
}
let server = ServerBuilder::from_cli(cli)
.admin_authorizer(Arc::new(TenantAuthorizer))
.start()
.await?;
Install nothing, change nothing. With no authorizer registered the api-key comparison decides alone, exactly as before.
Ordering is part of the contract
Authentication runs first and unconditionally; only then is the route parsed and the hook consulted. That order is load-bearing — if authentication ran after route parsing, an unauthenticated request to an unknown path would answer 404 instead of 401 and unknown-path responses would become a route-existence oracle for anonymous callers.
- Missing or invalid credential →
401, and the hook is not consulted. Denyon an authenticated request →403with the standard error envelope.- A path matching no route → the ordinary
404; the hook is not consulted, because nothing runs.
Actions
action is a stable string rather than an enum, so an embedder can extend its own vocabulary without waiting for an upstream release. The values upstream emits are constants in extensions::authz::actions:
| Action | Routes |
|---|---|
system.read | GET /, /health, /config, /logs, /metrics |
system.write | POST /admin/reload |
imposter.read | any GET under /imposters, and the per-imposter SSE alias |
imposter.write | mutating POST/PUT on an imposter, its stubs, scenarios or flow state |
imposter.delete | any DELETE under /imposters — and PUT /imposters, which reconciles the whole set and so removes everything not in the payload |
imposter.verify | POST /imposters/:port/verify, which mutates nothing |
events.read | GET /events, the cross-imposter stream |
intercept.read / intercept.write | /intercept and below |
events.read is separate from imposter.read on purpose: /events carries recorded requests from every imposter, so granting read on one port must not implicitly grant all of them.
Targeting: port, space, params, scope
port, space and params come from the router’s own parser, so they cannot drift from what the handler will actually act on.
scope is different. It is read verbatim from the x-rift-scope request header and exists because some routes have no target to key on — POST /imposters creates a port rather than naming one. Because it is a request header, it is caller-asserted: any authenticated caller can set it to any value. Cross-check it against what the credential entitles the caller to; never use it directly as the authorization subject.
The data plane is never authorized. Gateway traffic (/__rift/...) skips this hook for the same reason it skips the api key — it is app-under-test traffic, and requiring an admin identity for it would force the application to carry the admin credential.
Fronting the admin API yourself
Everything above assumes your requests reach upstream’s own request loop, which parses the route and calls the hook for you. If instead you terminate some admin routes yourself and proxy the rest, you have no parsed route to authorize against — so call the same classifier upstream does:
use rift_http_proxy::admin_api::authz::{SCOPE_HEADER, classify};
use rift_mock_core::extensions::authz::AuthzRequest;
// `None` = not an authorizable admin route: the data plane (`/__rift/...`), or a path that
// matches no route at all and so reaches no handler.
if let Some(target) = classify(req.method(), req.uri().path()) {
let params: Vec<(&str, &str)> = target
.params
.iter()
.map(|(name, value)| (*name, value.as_str()))
.collect();
let decision = my_authorizer.authorize(AuthzRequest {
credential: req.headers().get("authorization").and_then(|v| v.to_str().ok()),
action: target.action,
port: target.port,
space: target.space.as_deref(),
scope: req.headers().get(SCOPE_HEADER).and_then(|v| v.to_str().ok()),
params: ¶ms,
});
}
Do not write a second parser for this. Two parsers diverging is a silent bypass, and this codebase has already shipped it: a classifier that filtered empty path segments — which hyper does not normalise — saw a different route from the one dispatched, so PUT /imposters/:port/scenarios//state mutated a scenario the classifier had never seen. Under /imposters that is now unrepresentable, because classify calls the router’s own parser and matches its route enum exhaustively — which is exactly the property a hand-written copy gives up. SCOPE_HEADER likewise spares you a copied header literal.
The same applies to validating a request body you terminate. If you answer POST /imposters/:port/spaces/:flowId/stubs yourself, apply rift_http_proxy::admin_api::not_a_stub_reason(&payload) -> Option<String> (issue #1012). It returns the reason the body is not a stub, such as a {"stub": …} envelope or no recognised stub field, or None if the body is acceptable. Render the reason in your own error envelope as a 400. A private copy of the stub-field list goes stale when a field is added, and then rejects valid stubs.
Which routes exist: ADMIN_ROUTES
classify answers “what is this request”. A front that terminates some routes and forwards the rest also needs “what requests exist”, so it can check that every one is handled on one side. rift_http_proxy::admin_api::ADMIN_ROUTES is that set: every (method, path) pair the admin listener dispatches, with each path parameter named as classify reports it in AuthzTarget::params ({port}, {space}, {stubIndex}, {stubId}, {scenario}, {key}). The two event streams (/events and /imposters/{port}/savedRequests/stream) are GET only.
use rift_http_proxy::admin_api::{ADMIN_ROUTES, RouteFamily};
// Intercept routes are served only by a listener built `with_intercept`.
let served = ADMIN_ROUTES
.iter()
.filter(|route| route.family != RouteFamily::Intercept);
for route in served {
assert!(
i_handle(&route.method, route.path) || i_forward(&route.method, route.path),
"{} {} is served upstream and accounted for nowhere here",
route.method,
route.path,
);
}
Upstream’s tests hold the table to the listener: every per-imposter route variant and every authorization action must appear, every entry must be dispatched by a live listener, and every method the table does not list on a listed path must not be. A new route on an existing path, or a new per-imposter route, therefore reaches you as a new entry. A wholly new top-level path is the one addition those tests cannot see on their own, so it is a review rule upstream. The gateway (/__rift/...) is not in the table, for the same reason classify answers None for it.
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"
}]
}
Removed in 0.18.0: the top-level
error/feature/detailduplicates (deprecated in 0.16.0, #801) are gone;errors[0]is the only shape.
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. It still arrives when the engine ran the work on spawn_blocking (a store with is_blocking() == true, such as Redis). Before #987, annotations made on that thread were silently dropped. 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).