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
  • stateOps that never run - _rift.stateOps on a response that is not an is response

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):

curl http://localhost:2525/imposters/4545
{
  "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. Creating or changing stubs does not log them.


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
}

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
}

catch_all

A stub with empty predicates matches ALL requests:

{
  "stubs": [
    { "predicates": [], "responses": [{"is": {"body": "catch all"}}] }
  ]
}

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 but has no is body — a script-only _rift response, or the bare _rift form. stateOps runs only after an is response is rendered, so the operations never execute (see Flow State):

{
  "warningType": "state_ops_never_runs",
  "message": "Stub at index 0 has _rift.stateOps on a non-`is` response; stateOps only runs after an `is` response is rendered, so these operations never execute",
  "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 and recordMatches: true (imposter-level, no stubIndex), for a _rift block on a proxy, inject or fault response, 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. 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:

  1. Stubs are evaluated in order (index 0, 1, 2, …)
  2. The first stub whose predicates match is used
  3. 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

rift-verify normally SKIPs stubs whose response is dynamic (inject, proxy, script, cycling, _rift.fault, or a repeat, decorate, copy, lookup or shellTransform behavior) because their output isn’t a static function of the stub, and --skip-dynamic makes that skip explicit. A behavior key set to null or to an empty list configures nothing, so it does not make a stub dynamic. Passing --verify-dynamic instead asserts those stubs, using whichever of three mechanisms applies:

  1. proxy stubs — 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’s proxy config sets predicateGenerators) that a recorded stub is prepended.
  2. _verify-annotated stubs — the stub is recreated on a fresh throwaway imposter (clean cyclic/FSM state) and the declared request/expectation sequence is driven against it. This is the mechanism for inject/script/decorate/copy/lookup/cycling/repeat/stateful stubs, none of which the verifier can infer an expected response for on its own.
  3. Deterministic _rift.fault stubs — a fault whose probability is 1.0 or unset always fires, so the verifier can assert it directly: tcp as a transport-level reset, latency as an elapsed time at or above the configured delay, error as 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" } }
    ]
  }
}

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.