Spaces and flow state¶
A space is one imposter partitioned by a flow id, so concurrent tests can share a single imposter without seeing each other's stubs, scenario state or recorded traffic. The engine resolves the flow id from each incoming request — normally a header your system under test already propagates, like a tenant or correlation id — and keeps everything scoped to it separate.
This is what lets a suite run in parallel against one engine instead of one imposter per test.
Configuring the flow id¶
An imposter opts in by declaring where the flow id comes from:
Imposter imp = rift.create(imposter("orders")
.record()
.flowState(inMemoryFlowState().flowIdFromHeader("X-Tenant"))
.stub(onGet("/orders").willReturn(okJson("[]"))));
Without an explicit source the engine falls back to the imposter port, which means every request resolves to the same flow — so space-scoped stubs would never match. The SDK warns when it sees an imposter that uses spaces but declares no header source, because that combination is almost always a mistake rather than a choice.
redisFlowState(url) swaps the in-memory store for Redis when flow state has to outlive a single
engine or be shared across several.
Working in a space¶
imposter.space(flowId) is the flow-scoped view of the imposter:
Space acme = imp.space("acme");
acme.addStub(onGet("/orders").willReturn(okJson("[{\"id\":1}]"))); // only for X-Tenant: acme
List<RecordedRequest> seen = acme.recorded(); // only acme's traffic
acme.verify(onGet("/orders"), times(1)); // only acme's calls
acme.delete(); // drop this flow's state
Everything on a Space — stubs, recorded requests, verification — is confined to that flow. A stub
added to the default view still serves every flow; a stub added to a space serves only its own.
The DSL can place a stub in a space at creation time instead:
rift.create(imposter("orders")
.flowState(inMemoryFlowState().flowIdFromHeader("X-Tenant"))
.stub(onGet("/orders").willReturn(okJson("[]")).inSpace("acme")));
Flow-scoped scenario state¶
Scenarios are per-flow too, so two tests can drive the same state machine to different points concurrently:
imp.scenarios().setState("checkout", "PAID", "acme"); // acme's scenario only
imp.scenarios().setState("checkout", "NEW"); // the default flow
List<Scenarios.State> acmeStates = imp.scenarios().list("acme");
The three-argument setState(name, state, flowId) and list(flowId) are the flow-scoped forms; the
shorter overloads operate on the default flow.
Key/value flow state¶
imposter.flowState(flowId) reaches the same per-flow store the engine's scripts read and write:
FlowState state = imp.flowState("acme");
state.put("cartId", "c-123");
Optional<JsonValue> cartId = state.get("cartId");
state.delete("cartId");
Cursor reads over the journal¶
Polling recorded() re-reads everything every time. The cursor API reads only what is new:
RecordedPage page = imp.recordedPage(); // baseline: everything retained, plus a cursor
long cursor = page.nextIndex().orElse(0);
RecordedPage next = imp.recordedSince(cursor); // only what arrived since
cursor = next.nextIndex().orElse(cursor);
nextIndex() is opaque — pass it back verbatim and never derive one from a list offset. An empty
nextIndex() means the engine cannot offer a cursor; keep the one you hold and poll again. A present
0 is a real cursor meaning "nothing recorded yet", not an absent one.
truncated() says the retention cap evicted entries you had not seen — a hole in your view, so
re-baseline with recordedPage(). Deleting traffic you asked to delete is not a hole, so a cursor
held across a clearRecorded(...) is never flagged.
Spaces get the same cursor, pre-scoped to their flow:
Space acme = imp.space("acme");
RecordedPage acmePage = acme.recordedPage();
RecordedPage acmeNext = acme.recordedSince(acmePage.nextIndex().orElse(0));
The index is still the imposter's journal index, so a space cursor and an imposter cursor are the same number and interchangeable. A space tail's cursor therefore advances even while only other flows are recording — the engine moves it past entries the filter rejected, so a filtered tail never re-scans a range it has already judged, and an empty page with a moved cursor is normal.
Filtering with MatchClause¶
MatchClause filters a journal read engine-side, so the page costs only what it returns:
imp.recordedPage(MatchClause.method("POST"), MatchClause.path("/orders"));
imp.recordedSince(cursor, MatchClause.header("X-Tenant", "acme"));
imp.clearRecorded(MatchClause.flowId("acme")); // scoped delete
| Clause | Matches | Notes |
|---|---|---|
MatchClause.header(name, value) |
a request header | name case-insensitive, value exact |
MatchClause.flowId(id) |
the engine-resolved flow id | what a Space injects for you |
MatchClause.method(verb) |
the request method | case-sensitive — method("get") does not match a GET |
MatchClause.path(path) |
the bare request path | exact, still percent-encoded, and without the query string |
method and path require a rift engine ≥ 0.15.0. Against an older engine the clause is rejected
with a 400 surfaced as InvalidDefinition — it never quietly returns an unfiltered page, because a
filter that silently widened would mix in exactly the traffic it was meant to separate.
Clauses AND together, and they are applied after the cursor cut. The grammar is closed on
purpose: only the four forms above are constructible, so a clause that the engine would reject — or
one that could never match, like a path carrying a query string — fails at construction rather than
becoming a filter that quietly returns nothing forever.
Filtered reads need server-side filtering, and every transport can reach it — the in-process embedded
transport delegates them to its own admin server rather than evaluating clauses client-side. A
transport that could do neither would refuse with UnsupportedOperationException rather than answer
with the entries you asked to exclude; widening a filter is never the fallback.
Flow ids are never blank¶
Every flow-scoped call rejects a blank flow id with IllegalArgumentException — space(""),
flowState(" "), scenarios().setState(name, state, ""), scenarios().list(""), and
MatchClause.flowId("") alike.
A blank id is not "the default flow". It is a distinct, silently-wrong partition: stubs written there never match real traffic, reads come back mysteriously empty, and on the flow-state delete path it would target the wrong partition destructively. Because the failure is quiet and looks like "the feature doesn't work", it is rejected at the facade boundary rather than sent to the wire.
See also¶
- Event stream — push notification of the same recorded requests, with
flowIdon each event. - API design — the full reference for
Space,MatchClauseand the cursor contract.