Stub Analysis¶
Rift provides automated stub analysis to detect common configuration issues that can lead to unexpected behavior.
Overview¶
When you create or modify stubs, Rift analyzes them for potential problems:
- Duplicate IDs - Multiple stubs with the same identifier
- Shadowed stubs - Stubs that will never match due to earlier stubs
- Catch-all ordering - Empty predicate stubs that shadow subsequent stubs
- Exact duplicates - Stubs with identical predicates
stateOpsthat never run -_rift.stateOpson a response that is not anisresponse
Note: This is a Rift extension. Mountebank does not provide overlap detection or warnings.
Viewing Warnings¶
Warnings appear in the GET /imposters/:port response under the _rift.warnings field (the
_rift object is omitted when there are no warnings and no flowState to report):
{
"port": 4545,
"protocol": "http",
"stubs": [...],
"_rift": {
"warnings": [
{
"warningType": "catch_all_not_last",
"message": "Catch-all stub at index 0 will shadow 2 stub(s) after it",
"stubIndex": 0
},
{
"warningType": "potentially_shadowed",
"message": "Stub at index 1 may be shadowed by catch-all stub at index 0",
"stubIndex": 1,
"shadowedByIndex": 0
}
]
}
}
Each GET /imposters/:port that returns warnings also logs them at warn level, except
config_key_ignored (see below), which is logged once when the imposter loads. Creating an imposter
or changing its stubs through the admin API logs them too, because those replies are built the same
way as a GET. Loading through --configfile, --datadir or the C-ABI logs only the config_key_ignored
warnings, until something reads the imposter.
Warning Types¶
duplicate_id¶
Multiple stubs have the same id field:
{
"stubs": [
{ "id": "user-stub", "predicates": [{"equals": {"path": "/a"}}], "responses": [...] },
{ "id": "user-stub", "predicates": [{"equals": {"path": "/b"}}], "responses": [...] }
]
}
Warning:
{
"warningType": "duplicate_id",
"message": "Stub at index 1 has duplicate ID 'user-stub' (same as stub at index 0)",
"stubIndex": 1,
"stubId": "user-stub",
"shadowedByIndex": 0
}
exact_duplicate¶
Two stubs have identical predicates. The second stub will never match:
{
"stubs": [
{ "predicates": [{"equals": {"path": "/test"}}], "responses": [{"is": {"body": "first"}}] },
{ "predicates": [{"equals": {"path": "/test"}}], "responses": [{"is": {"body": "second"}}] }
]
}
Warning:
{
"warningType": "exact_duplicate",
"message": "Stub at index 1 has identical predicates to stub at index 0 and will never match",
"stubIndex": 1,
"shadowedByIndex": 0
}
A stub gated on a different requiredScenarioState or space is not a duplicate, since only one of them is eligible at a time (for example the Started and paid stubs of a scenario). An ungated stub ahead of a gated one still is.
potentially_shadowed¶
A stub may be unreachable because an earlier stub matches a superset of requests:
{
"stubs": [
{ "predicates": [{"startsWith": {"path": "/api"}}], "responses": [...] },
{ "predicates": [{"equals": {"path": "/api/users"}}], "responses": [...] }
]
}
Warning:
{
"warningType": "potentially_shadowed",
"message": "Stub at index 1 may be partially shadowed by stub at index 0 which has overlapping predicates",
"stubIndex": 1,
"shadowedByIndex": 0
}
A stub gated on a different requiredScenarioState or space is not shadowed, since only one of them is eligible at a time. An ungated stub ahead still shadows a gated one.
catch_all¶
A stub with empty predicates matches ALL requests:
Warning:
{
"warningType": "catch_all",
"message": "Stub at index 0 has empty predicates and will match ALL requests",
"stubIndex": 0
}
catch_all_not_last¶
A catch-all stub appears before other stubs, shadowing them:
{
"stubs": [
{ "predicates": [], "responses": [{"is": {"body": "catch all"}}] },
{ "predicates": [{"equals": {"path": "/specific"}}], "responses": [{"is": {"body": "specific"}}] }
]
}
Warning:
{
"warningType": "catch_all_not_last",
"message": "Catch-all stub at index 0 will shadow 1 stub(s) after it",
"stubIndex": 0
}
state_ops_never_runs¶
A response carries _rift.stateOps on a script response (_rift.script). stateOps runs only
after an is response is rendered, so the operations never execute. A bare _rift response without
a script is served as is: {}, so its stateOps run and it is not flagged (see Flow State):
{
"warningType": "state_ops_never_runs",
"message": "Stub at index 0 has _rift.stateOps on a script response (`_rift.script`); stateOps only runs after an `is` response is rendered, so these operations never execute",
"stubIndex": 0
}
conditional_never_runs¶
A response carries _rift.conditional but it can never answer 304 (issue #1296): it is on a
script response (_rift.script; conditional GET reads only an is response), or on an is
response (or a bare _rift response without a script, which is served as is: {}) in a stub whose top-level equals/deepEquals method predicate is a single string other than
GET/HEAD. A method under or/not/and, or matched with matches/exists, is not judged.
rift-lint reports the same shapes as W019.
{
"warningType": "conditional_never_runs",
"message": "Stub at index 0 has _rift.conditional but its method predicate (POST) excludes GET and HEAD, so it never answers 304",
"stubIndex": 0
}
config_key_ignored¶
A key the engine parses and does not act on (issue #1152). The value reads back unchanged, so
nothing else would distinguish "honoured" from "dropped". Reported for _rift.metrics,
_rift.proxy, recordMatches: true, ciphers on an https imposter and defaultResponse.proxy (imposter-level, no stubIndex), for a _rift block on
a proxy, inject or fault response, for a _rift.script beside an is response (only a
stored or --datadir config an earlier engine admitted can carry one: every config door refuses it,
issue #1729, and rift-lint reports it as E056, not W017), and for a _behaviors/behaviors block setting anything
besides repeat on a fault or _rift-only response (issues #1181, #1188, #1189) — one entry per key and shape,
naming the stubs, with the first as stubIndex. A proxy predicateGenerators entry with a key the
engine does not read (a typo such as matchs; issue #1327) is one entry naming the keys and the
stubs, and rift-lint reports it as W020. The imposter-level keys and the stubs present when the imposter is created are also
logged at WARN then, for doors that never see a response (--configfile, --datadir, the C-ABI);
a stub added later is reported in _rift.warnings only. rift-lint flags the same keys as W017:
{
"warningType": "config_key_ignored",
"message": "`recordMatches` has no effect: this engine does not record per-stub `matches`; use `recordRequests` and GET /imposters/:port to see the requests"
}
truncated¶
Analysis retains at most 100 warnings per imposter. If more are produced (e.g. hundreds of
overlapping stubs), a single truncated summary records how many were suppressed instead of
returning an unbounded list:
{
"warningType": "truncated",
"message": "412 additional stub warning(s) suppressed (showing first 100)"
}
Performance & availability¶
Analysis is computed lazily, once per stub change: a stub mutation only invalidates the cached
result, and the next read (GET /imposters/:port, or rift_stub_warnings over FFI) recomputes and
caches it. Exact-duplicate detection is O(n) (hash-based), and warnings are capped (see
truncated). The pairwise "partially shadowed by an overlapping stub" check is O(n²), so it is
skipped on imposters with more than 200 stubs; exact duplicates, catch-alls and shadowing by a
catch-all are still reported at any size.
Because the analysis now lives in the engine, embedded consumers get it too: over the C-ABI,
call rift_stub_warnings(handle, port) to retrieve the same warnings as a JSON array (see
Embedding — FFI).
Stub ID Field¶
Rift extends the stub schema with an optional id field for easier management:
{
"stubs": [
{
"id": "get-users",
"predicates": [{"equals": {"method": "GET", "path": "/users"}}],
"responses": [{"is": {"statusCode": 200, "body": "[]"}}]
},
{
"id": "create-user",
"predicates": [{"equals": {"method": "POST", "path": "/users"}}],
"responses": [{"is": {"statusCode": 201}}]
}
]
}
Benefits:
- Easier to identify stubs in logs and warnings
- Self-documenting stub configurations
- Addressable through the admin API — see Stub by ID
Note: The id field is ignored by Mountebank but preserved by Rift.
First-Match-Wins Semantics¶
Both Mountebank and Rift use first-match-wins semantics:
- Stubs are evaluated in order (index 0, 1, 2, ...)
- The first stub whose predicates match is used
- Subsequent stubs are not evaluated
Example¶
{
"stubs": [
{
"predicates": [{"startsWith": {"path": "/api"}}],
"responses": [{"is": {"body": "general"}}]
},
{
"predicates": [{"equals": {"path": "/api/users"}}],
"responses": [{"is": {"body": "specific"}}]
}
]
}
| Request | Matches | Response |
|---|---|---|
| GET /api/users | Stub 0 (startsWith /api) | "general" |
| GET /api/orders | Stub 0 (startsWith /api) | "general" |
| GET /other | No match | Default response |
To get "specific" for /api/users, swap the stub order:
{
"stubs": [
{
"predicates": [{"equals": {"path": "/api/users"}}],
"responses": [{"is": {"body": "specific"}}]
},
{
"predicates": [{"startsWith": {"path": "/api"}}],
"responses": [{"is": {"body": "general"}}]
}
]
}
Best Practices¶
1. Order stubs from specific to general¶
{
"stubs": [
{ "predicates": [{"equals": {"path": "/api/users/123"}}], ... },
{ "predicates": [{"equals": {"path": "/api/users"}}], ... },
{ "predicates": [{"startsWith": {"path": "/api"}}], ... },
{ "predicates": [], ... } // Catch-all last
]
}
2. Use unique IDs for each stub¶
{
"stubs": [
{ "id": "get-user-by-id", ... },
{ "id": "list-users", ... },
{ "id": "api-fallback", ... }
]
}
3. Place catch-all stubs last¶
{
"stubs": [
{ "predicates": [{"equals": {"path": "/health"}}], ... },
{ "predicates": [{"equals": {"path": "/ready"}}], ... },
{ "predicates": [], "responses": [{"is": {"statusCode": 404}}] } // Last
]
}
4. Check warnings after creating imposters¶
# Create imposter
curl -X POST http://localhost:2525/imposters -d @imposter.json
# Check for warnings
curl http://localhost:2525/imposters/4545 | jq '._rift.warnings'
5. Different method = different stub (no conflict)¶
Stubs with the same path but different methods don't conflict:
{
"stubs": [
{ "predicates": [{"equals": {"path": "/users", "method": "GET"}}], ... },
{ "predicates": [{"equals": {"path": "/users", "method": "POST"}}], ... }
]
}
The _verify annotation¶
A stub whose response is dynamic (inject, proxy, script, cycling, _rift.fault, or a
repeat, decorate, copy, lookup or shellTransform behavior) has output that isn't a static
function of the stub. By default rift-verify still requests it and passes any 2xx status (plus the
headers and body of its first is response, when it has one); --skip-dynamic reports it as a
SKIP instead. A behavior key set to null or to an empty list configures nothing, so it does not
make a stub dynamic. Passing --verify-dynamic asserts those stubs properly, using whichever of three
mechanisms applies:
proxystubs — an embedded mock upstream is stood up and the proxy stub is recreated pointing at it; the verifier asserts the proxied response comes back, and (when the stub'sproxyconfig setspredicateGenerators) that a recorded stub is prepended._verify-annotated stubs — the imposter is recreated on a fresh throwaway port (clean cyclic/FSM state) from its full?replayable=trueconfig, sodefaultResponse,defaultForward,_riftand TLS settings carry over, and the declared request/expectationsequenceis driven against it. A step may therefore expect the imposter'sdefaultResponsefor a request no stub matches. This is the mechanism forinject/script/decorate/copy/lookup/cycling/repeat/stateful stubs, none of which the verifier can infer an expected response for on its own.- Deterministic
_rift.faultstubs — a fault whoseprobabilityis1.0or unset always fires, so the verifier can assert it directly:tcpas a transport-level reset,latencyas an elapsed time at or above the configured delay,erroras the configured status code.
A dynamic stub that carries none of these markers is still surfaced as a visible SKIP in the
output — it is never silently dropped.
_verify is a stub-level annotation (a sibling of predicates/responses) that declares a sequence
of requests and expected outcomes to drive against a fresh copy of the imposter:
{
"predicates": [{ "equals": { "path": "/orders/77" } }],
"responses": [{
"_rift": {
"script": {
"engine": "rhai",
"code": "fn respond(ctx) { let n = ctx.state.incr(\"attempts\"); if n <= 1 { http(503) } else { http(200, `order 77 ready`) } }"
}
}
}],
"_verify": {
"sequence": [
{ "request": { "method": "GET", "path": "/orders/77" }, "expect": { "status": 503 } },
{ "request": { "path": "/orders/77" }, "expect": { "status": 200, "bodyContains": "ready" } }
]
}
}
--verify-dynamic runs the _verify sequence in addition to the ordinary request, not instead of it.
The ordinary request still has to see a 2xx, so the example above, whose first answer is 503,
reports one FAIL for that request even though both _verify steps pass. Add --skip-dynamic to
report the ordinary request as a SKIP and let the _verify steps decide the result.
Fields:
| Field | Default | Notes |
|---|---|---|
sequence |
[] |
Ordered list of { request, expect } steps, driven one after another against the same fresh imposter. |
request.method |
"GET" |
HTTP method for the step. |
request.path |
required | Request path. |
request.body |
— | Optional request body. |
request.headers |
{} |
Optional request headers. One entry per name — under --verify-dynamic, a name spelled twice (X-Trace and x-trace) is rejected as a malformed _verify. |
expect.status |
— | Expected status code; omit to ignore status. |
expect.bodyContains |
— | Substring the response body must contain. |
expect.bodyEquals |
— | Exact response body match. |
An expect with no fields set matches any response. A malformed _verify block (e.g. a step missing
request) fails that stub's check rather than being silently skipped.
See CLI Reference → rift-verify for
--skip-dynamic and --verify-dynamic.
Mountebank Compatibility¶
| Feature | Mountebank | Rift |
|---|---|---|
| First-match-wins | Yes | Yes |
| Overlap detection | No | Yes (warnings) |
| Stub IDs | No | Yes |
| Warning messages | No | Yes |
| Duplicate allowed | Yes | Yes (with warning) |
The id field and _rift.warnings are Rift extensions that don't affect Mountebank compatibility. Mountebank will ignore the id field if present.