Flow State
Flow state is a per-flow key/value store that scripts read and write to build stateful mocks — retry-then-succeed, call counters, saga progress. It is keyed by (flow_id, key), where the flow id is resolved exactly as for Spaces.
Configuration
{
"_rift": {
"flowState": {
"backend": "inmemory",
"ttlSeconds": 300,
"flowIdSource": "header:X-Flow-Id"
}
}
}
| Field | Default | Notes |
|---|---|---|
backend | inmemory | inmemory or redis. |
ttlSeconds | 300 | Default entry TTL, in seconds. Must be >= 1 (see TTL semantics). |
flowIdSource | imposter_port | How the flow id is derived (imposter_port or header:<Name>). |
With backend: "redis" you may also set url, poolSize (default 10), and keyPrefix (default "rift:").
Backend configuration is fail-loud
An explicit flowState block that can’t be honored now fails imposter creation with 400 Bad Request rather than silently downgrading to a no-op store:
- An unknown backend string (anything other than
inmemoryorredis) is rejected at construction. - A
redisbackend that can’t be created — no redis config block, a connection/pool failure, or a binary built without theredis-backendfeature — fails creation too. - A non-positive
ttlSeconds(< 1) is rejected: a zero/negative default TTL would expire every write the instant it lands (and errors on the first RedisSETEX), so it’s caught up front rather than misbehaving later.
Only imposters with genuinely no state surface stay on the silent no-op store: no flowState block, no scenario stubs, and no _rift.script stub. Such an imposter never touches the store, so there is nothing to auto-provision.
No flowState, but a script or scenario stub — auto-provisioned in-memory
An imposter with a _rift.script stub (which might call ctx.state at runtime) or a scenario stub, but no flowState block, gets a real in-memory store auto-provisioned at the default TTL (300s) — a tracing::warn! (target rift::script) is logged so this doesn’t go unnoticed, and rift-lint flags the same condition statically as E042. State works out of the box; it just doesn’t persist across restarts or get shared across a cluster the way an explicit flowState (especially backend: "redis") would.
Script API
Scripts read and write flow state through the ctx.state handle, which is pre-scoped to the request’s resolved flow id — no flow id is passed per call. See Scripting → ctx.state and ctx.store for the full surface:
| Call | Result |
|---|---|
ctx.state.get(key) | value, or () / nil / null if absent |
ctx.state.get_or(key, default) | value, or default if absent |
ctx.state.set(key, value) | store a value |
ctx.state.exists(key) | bool |
ctx.state.delete(key) | remove a key |
ctx.state.incr(key) / incr_by(key, n) | atomic increment, returns the new number |
ctx.state.cas(key, expected, new) | atomic compare-and-set — { applied: … } |
ctx.state.ttl(seconds) | re-stamp the TTL of every key currently in this flow |
ctx.state.ttl(key, seconds) | set one key’s TTL — true if it existed, false if absent; seconds <= 0 deletes it |
ctx.state.clear() | remove every key in this flow |
ctx.store.flow(id) | a handle scoped to a different flow id (cross-flow access) |
Every ctx.state call is fail-loud: a backend error (e.g. Redis dropping mid-request) raises a script error and is logged — it is never silently swallowed into a default. In JavaScript the method names are camelCase (getOr/incrBy); cas/ttl/clear are spelled the same in both engines.
TTL semantics
TTL is a per-key attribute, and every backend applies the same rules:
- Every write stamps the default TTL. A
set/incr/incr_by/applied-cas(re)stamps the key’s expiry tonow + ttlSeconds. - Writes reset the TTL.
ttl(key, s)overrides a key’s expiry, but a subsequent write to that key re-stamps it back to the defaultttlSeconds— TTLs are set by the last write (this is exactly what RedisSETEXdoes; there is no sticky/KEEPTTLmode). seconds <= 0expires immediately.ttl(key, 0)(or negative) deletes the key now; flow-levelttl(0)expires every current key in the flow. This mirrors RedisEXPIRE, giving scripts both “shrink the lifetime” (ttl(key, 5)) and “kill now” (ttl(key, 0)) with one primitive.- Flow-level
ttl(seconds)is a convenience over the per-key primitive: it re-stamps every key currently in the flow. On Redis it does an O(keys-in-flow)SCAN+EXPIRE; on both backends the observable result is identical. - There is deliberately no “no expiry” mode — an unexpirable store is a memory leak by construction — so
ttlSecondsis mandatory and must be>= 1.
Example — fail twice, then succeed
Requires --allow-injection. The counter is keyed by the X-Flow-Id header so each caller retries independently.
Using the ctx.state API, ctx.state.incr("attempts") is already scoped to the caller’s flow id (per flowIdSource below) — no flow-id prelude, no if n == () { n = 0; } default dance:
{
"port": 4506,
"protocol": "http",
"_rift": {
"flowState": { "backend": "inmemory", "ttlSeconds": 300, "flowIdSource": "header:X-Flow-Id" }
},
"stubs": [{
"predicates": [{ "equals": { "method": "GET", "path": "/api/resource" } }],
"responses": [{
"_rift": {
"script": {
"engine": "rhai",
"code": "fn respond(ctx) { let n = ctx.state.incr(\"attempts\"); if n <= 2 { http(503, `try ${n}`) } else { http(200, `ok ${n}`) } }"
}
}
}]
}]
}
curl -i -H 'X-Flow-Id: t1' http://localhost:4506/api/resource # 503 try 1
curl -i -H 'X-Flow-Id: t1' http://localhost:4506/api/resource # 503 try 2
curl -i -H 'X-Flow-Id: t1' http://localhost:4506/api/resource # 200 ok 3
Inspecting and arranging state (admin API)
# Read a value (404 if the key is absent)
curl http://localhost:2525/admin/imposters/4506/flow-state/t1/attempts
# Set a value directly
curl -X PUT http://localhost:2525/admin/imposters/4506/flow-state/t1/attempts \
-d '{"value": 0}'
# Delete a key
curl -X DELETE http://localhost:2525/admin/imposters/4506/flow-state/t1/attempts
# Clear an entire flow (all keys) — the test-arrange/teardown tool for resetting a flow
# between scenario runs. Idempotent: clearing an absent/empty flow still returns 200.
curl -X DELETE http://localhost:2525/admin/imposters/4506/flow-state/t1
Note: an imposter gets a real store when _rift.flowState is configured, or it declares scenario stubs, or it has a _rift.script stub (auto-provisioned in-memory); only an imposter with none of those uses a no-op store where values never persist.
Embedding over the C-ABI (non-Rust): a non-Rust host can read, write, and delete flow-state keys with zero loopback HTTP via FFI (C-ABI) —
rift_flow_state_get/rift_flow_state_put/rift_flow_state_deletemirror the admin-API calls above exactly (sameImposterManagercalls, same JSON shapes).