Building Stateful Mock Services with Flow State¶
From simple stubs to multi-step test scenarios
This article is for developers whose mocks need to remember earlier requests. By the end you will have counters, a login flow, a shopping cart and a quota built on Rift's flow state, isolated per caller, and you will know how to inspect and reset that state from a test.
Most mock servers are stateless. Each request is independent. But real APIs have state:
- Login creates a session
- Adding items updates a cart
- Quotas track request counts
- Retries succeed after transient failures
Understanding Flow State¶
Flow state is a key/value store keyed by (flow id, key). Three things use it:
_rift.stateOpswrites it declaratively on anisresponse — no script needed.{{ state.<key> }}reads it back into a templated response (_rift.templated: true).ctx.statereads and writes it from a script.
The imposter's _rift.flowState block configures the store:
{
"port": 4545,
"protocol": "http",
"_rift": {
"flowState": {
"backend": "inmemory",
"ttlSeconds": 300,
"flowIdSource": "header:X-User-Id"
}
},
"stubs": []
}
| Field | Default | Meaning |
|---|---|---|
backend |
inmemory |
inmemory, or redis for state shared across instances |
ttlSeconds |
300 |
Lifetime of a key, restarted by every write to it. Must be at least 1 |
flowIdSource |
imposter_port |
What a flow is: the whole imposter, or header:<Name> for one flow per header value |
With flowIdSource: "header:X-User-Id", requests carrying X-User-Id: alice and
X-User-Id: bob see separate state — per-user isolation without building keys by hand.
For Redis, the connection settings go in a nested redis block:
{
"_rift": {
"flowState": {
"backend": "redis",
"ttlSeconds": 600,
"redis": { "url": "redis://localhost:6379", "poolSize": 10, "keyPrefix": "rift:" }
}
}
}
A flowState block Rift cannot honour — an unknown backend, an unreachable Redis, a ttlSeconds
below 1 — fails imposter creation with 400 rather than silently running without state. An
imposter that uses state but has no flowState block gets an in-memory store automatically, and
rift-lint reports it as W014 so the choice is deliberate.
A Counter Without a Script¶
stateOps runs after the response is rendered, just before it is written. Here one stub records a
visit and another reads the totals back:
{
"port": 4545,
"protocol": "http",
"_rift": {
"flowState": { "backend": "inmemory", "ttlSeconds": 300 }
},
"stubs": [
{
"predicates": [{ "equals": { "method": "POST", "path": "/visits" } }],
"responses": [{
"is": { "statusCode": 202 },
"_rift": {
"stateOps": [
{ "op": "increment", "key": "visits" },
{ "op": "set", "key": "lastPage", "value": "{{ request.query.page }}" }
]
}
}]
},
{
"predicates": [{ "equals": { "method": "GET", "path": "/visits" } }],
"responses": [{
"is": { "statusCode": 200, "body": "{{ state.visits }} visits, last page: {{ state.lastPage }}" },
"_rift": { "templated": true }
}]
}
]
}
curl -X POST 'http://localhost:4545/visits?page=home' # 202
curl -X POST 'http://localhost:4545/visits?page=cart' # 202
curl http://localhost:4545/visits # 2 visits, last page: cart
The ops are increment (with an optional by), set (the value is a template), delete and
clearFlow. stateOps is not scripting, so it works without --allow-injection.
Scripted State: Authentication Flow¶
When the logic branches, use a script. ctx.state is already bound to the request's flow, so with
flowIdSource: "header:X-User-Id" each user gets their own session:
{
"port": 4545,
"protocol": "http",
"_rift": {
"flowState": { "backend": "inmemory", "ttlSeconds": 3600, "flowIdSource": "header:X-User-Id" }
},
"stubs": [
{
"predicates": [{ "equals": { "method": "POST", "path": "/login" } }],
"responses": [{
"_rift": { "script": { "engine": "rhai",
"code": "ctx.state.set(\"session\", \"sess-\" + ctx.flowId);\nhttp(200, #{ loggedIn: true, user: ctx.flowId })" } }
}]
},
{
"predicates": [{ "equals": { "method": "GET", "path": "/profile" } }],
"responses": [{
"_rift": { "script": { "engine": "rhai",
"code": "if ctx.state.exists(\"session\") {\n http(200, #{ user: ctx.flowId, session: ctx.state.get(\"session\") })\n} else {\n http(401, #{ error: \"not logged in\" })\n}" } }
}]
},
{
"predicates": [{ "equals": { "method": "POST", "path": "/logout" } }],
"responses": [{
"_rift": { "script": { "engine": "rhai",
"code": "ctx.state.clear();\nhttp(204)" } }
}]
}
]
}
Start Rift with --allow-injection, then:
curl -H 'X-User-Id: alice' http://localhost:4545/profile # 401 {"error":"not logged in"}
curl -H 'X-User-Id: alice' -X POST http://localhost:4545/login # 200 {"loggedIn":true,"user":"alice"}
curl -H 'X-User-Id: alice' http://localhost:4545/profile # 200 {"session":"sess-alice","user":"alice"}
curl -H 'X-User-Id: bob' http://localhost:4545/profile # 401 — bob has his own flow
curl -H 'X-User-Id: alice' -X POST http://localhost:4545/logout # 204
curl -H 'X-User-Id: alice' http://localhost:4545/profile # 401
Scripted State: Shopping Cart¶
State values can be arrays and objects. The same flow settings, in JavaScript (getOr is the
JavaScript spelling of Rhai's get_or):
{
"stubs": [
{
"predicates": [{ "equals": { "method": "POST", "path": "/cart" } }],
"responses": [{
"_rift": { "script": { "engine": "javascript",
"code": "function respond(ctx) {\n const cart = ctx.state.getOr('cart', []);\n cart.push(ctx.request.json);\n ctx.state.set('cart', cart);\n return http(201, { items: cart.length });\n}" } }
}]
},
{
"predicates": [{ "equals": { "method": "GET", "path": "/cart" } }],
"responses": [{
"_rift": { "script": { "engine": "javascript",
"code": "function respond(ctx) {\n const cart = ctx.state.getOr('cart', []);\n const total = cart.reduce((sum, i) => sum + i.price * i.qty, 0);\n return http(200, { items: cart, total });\n}" } }
}]
}
]
}
curl -H 'X-User-Id: alice' -X POST http://localhost:4545/cart -d '{"sku":"A1","price":2.5,"qty":2}' # 201 {"items":1}
curl -H 'X-User-Id: alice' -X POST http://localhost:4545/cart -d '{"sku":"B2","price":10,"qty":1}' # 201 {"items":2}
curl -H 'X-User-Id: alice' http://localhost:4545/cart
# {"items":[{"price":2.5,"qty":2,"sku":"A1"},{"price":10,"qty":1,"sku":"B2"}],"total":15}
Scripted State: Quota Exhaustion¶
{
"predicates": [{ "startsWith": { "path": "/api/" } }],
"responses": [{
"_rift": { "script": { "engine": "rhai",
"code": "let used = ctx.state.incr(\"calls\");\nif used > 3 {\n http(429, #{ error: \"quota exceeded\", limit: 3 }).header(\"Retry-After\", \"60\")\n} else {\n http(200, #{ ok: true, remaining: 3 - used })\n}" } }
}]
}
The first three calls answer 200 with remaining counting down to 0; the fourth answers 429.
incr is atomic, so concurrent callers never double-spend the quota.
A key's TTL restarts on every write, so a counter that is written on every request never expires while traffic keeps arriving. Reset it explicitly between tests, as below.
Inspecting and Resetting State from Tests¶
The admin API reads and writes one flow's keys, which is how a test arranges state before it runs and cleans up after:
# Read a key (404 if absent)
curl http://localhost:2525/admin/imposters/4545/flow-state/alice/calls
# {"flowId":"alice","key":"calls","value":4}
# Set a key
curl -X PUT http://localhost:2525/admin/imposters/4545/flow-state/alice/calls -d '{"value": 0}'
# Delete one key
curl -X DELETE http://localhost:2525/admin/imposters/4545/flow-state/alice/calls
# Clear the whole flow (idempotent)
curl -X DELETE http://localhost:2525/admin/imposters/4545/flow-state/alice
Mountebank Cycling vs Rift Flow State¶
| Feature | Mountebank response cycling | Rift flow state |
|---|---|---|
| Scope | Per stub, shared by every caller | Per flow — per imposter, or per header value |
| Persistence | Memory only | In-memory or Redis |
| Reset | Restart, or re-create the imposter | Key TTL, or the flow-state admin API |
| Logic | Fixed sequence | Declarative stateOps, or a script |
When to use which:
- Cycling: a fixed round-robin of responses
- Flow state: responses that depend on what a caller did before
Best Practices¶
- Prefer
stateOpswhen it fits: no script, no--allow-injection - Key flows by caller:
flowIdSource: "header:<Name>"isolates parallel tests - Use Redis when state must be shared across Rift instances
- Reset state in test setup:
DELETE /admin/imposters/<port>/flow-state/<flow> - Configure
flowStateexplicitly: silenceW014by making the store a choice
For the full reference — TTL rules, cas, ctx.store, stateOps failure semantics — see
Flow State in the Rift docs.
What's Next?¶
We've used Rhai and JavaScript scripts throughout this post. Let's dive deeper into scripting:
Next Post: Dynamic Responses with Rhai and JavaScript Scripting
Tags: #StatefulTesting #MockServer #Rift #APITesting #FlowState