Responses

Responses define what an imposter returns when a stub’s predicates match.


Response Types

is (Static Response)

Return a fixed response:

{
  "is": {
    "statusCode": 200,
    "headers": {
      "Content-Type": "application/json",
      "X-Custom-Header": "value"
    },
    "body": {
      "message": "Success",
      "data": { "id": 1 }
    }
  }
}

proxy (Forward Request)

Forward requests to a real server and optionally record responses:

{
  "proxy": {
    "to": "https://api.example.com",
    "mode": "proxyAlways",
    "predicateGenerators": [{
      "matches": { "path": true, "method": true }
    }]
  }
}

inject (Dynamic Response)

Generate responses with JavaScript (requires --allowInjection):

{
  "inject": "function(request, state, logger) { return { statusCode: 200, body: 'Request path: ' + request.path }; }"
}

fault (Connection Fault)

Break the connection instead of answering, as Mountebank’s fault response does:

{ "fault": "CONNECTION_RESET_BY_PEER" }

CONNECTION_RESET_BY_PEER and RANDOM_DATA_THEN_CLOSE are Mountebank’s names; Rift also accepts EMPTY_RESPONSE, MALFORMED_RESPONSE_CHUNK and the short aliases listed in Fault Injection. An unrecognised name is served as a 500 with an Unknown fault body.

Where behaviors and _rift apply

_behaviors/behaviors and a response-level _rift block are read on an is response (and the flat form below). On a proxy, inject or fault response they are ignored.


Static Responses (is)

Status Codes

{ "is": { "statusCode": 201 } }
{ "is": { "statusCode": 400 } }
{ "is": { "statusCode": 500 } }

Status codes can also be specified as strings for compatibility with some tools:

{ "is": { "statusCode": "200" } }
{ "is": { "statusCode": "404" } }

Headers

{
  "is": {
    "statusCode": 200,
    "headers": {
      "Content-Type": "application/json",
      "Cache-Control": "no-cache",
      "X-Request-Id": "abc123"
    }
  }
}

Non-string header values are accepted and coerced. JSON numbers and booleans are converted to their string form, matching Mountebank, so a config like this is valid rather than a 400:

{ "is": { "headers": { "Content-Length": 124, "X-Cache-Hit": true } } }

sends Content-Length: 124 and X-Cache-Hit: true. Arrays may mix types the same way; null, objects and nested arrays are still rejected.

Automatic Content-Type. When a response has a JSON body (an object or array, not a string) and the stub sets no Content-Type header, Rift adds Content-Type: application/json. The check is case-insensitive — configuring content-type, CONTENT-TYPE, or any other casing suppresses the default rather than producing a duplicate header. A string body never gets an automatic Content-Type.

Body Types

String body:

{ "is": { "body": "Hello, World!" } }

JSON body (auto-serialized):

A JSON body is re-serialized, so its numbers are printed from their parsed value. Rift parses floats with correct rounding, so {"n": 7e23} is served as 7e23 and a 17-digit double such as 0.10018513143495411 keeps its digits. Only a number wider than a 64-bit integer, or with more significant digits than a double holds, is served rounded; send such a value as a string body if it must be exact.

{
  "is": {
    "body": {
      "users": [
        { "id": 1, "name": "Alice" },
        { "id": 2, "name": "Bob" }
      ]
    }
  }
}

XML body:

{
  "is": {
    "headers": { "Content-Type": "application/xml" },
    "body": "<?xml version=\"1.0\"?><user><id>1</id></user>"
  }
}

Binary body (base64):

{
  "is": {
    "body": "SGVsbG8gV29ybGQ=",
    "_mode": "binary"
  }
}

The body is standard base64 (with padding); _mode: "binary" tells Rift to decode it before serving. Omit _mode (or set "text") for a normal text/JSON body.


Request Interpolation

Static (is) responses can echo values from the incoming request using ${request.…} tokens. Rift substitutes them into the response body and header values before any behaviors run:

Token Resolves to
${request.path} Request path
${request.method} HTTP method
${request.body} Raw request body
${request.query.<name>} Query parameter <name>. A key repeated in the URL renders comma-joined, in order?color=red&color=green gives red,green — the same value a query predicate matches against
${request.headers.<name>} Request header <name> (case-insensitive)
${request.pathParams.<name>} Path parameter <name> captured from the stub’s routePattern

${request.pathParams.<name>} only resolves when the stub declares a top-level routePattern (e.g. /users/:id) whose shape matches the request path — see Route patterns.

{
  "is": {
    "statusCode": 200,
    "headers": { "X-Echo-Path": "${request.path}" },
    "body": "You called ${request.method} ${request.path} with q=${request.query.q}"
  }
}

A request GET /search?q=rust returns You called GET /search with q=rust and an X-Echo-Path: /search header.

A header value can only carry certain bytes, and an interpolated one is built from data the client sent. When a ${request.…} token appears in a header value, Rift removes the characters a header value cannot hold — CR, LF, NUL and the other ASCII control characters — from the substituted text, and logs a rift::template warning naming what it removed. The warning carries port, stub (the stub’s index) and stub_id alongside removed, so on a server running many imposters you can grep straight back to the stub that produced it. A horizontal tab and any non-ASCII character are legal in a header value and are kept byte-exact. The same repair covers every other way substituted text reaches a header: the {{ }} templating grammar and the text the copy and lookup behaviors splice in.

The repair is deliberately narrow: it covers only the substituted value, never the literal text you wrote around it. A control character written literally into a header in your config is an authoring error rather than client data, so it still fails that response with a 500 — even when the same header also contains a token. Response bodies are never filtered this way.

These ${request.…} tokens are distinct from the free-form ${name} placeholders that the copy and lookup behaviors fill in — the two do not collide, because only tokens beginning with request. are treated as request interpolation. On the proxy path, only the body is interpolated (not headers).

What a token substitutes is served as the client sent it and is never read as a ${request.…}, copy or lookup token again. With _rift.templated, a {{request.query.q}} that renders ${request.headers.authorization} stays that literal text: it does not reflect a request header the client named. Likewise a copy or lookup never expands a token that arrived inside interpolated text, even when your own text supplies its closing character. See Substituted text is never re-scanned.


Response Cycling

When a stub has multiple responses, they cycle through in round-robin order. Each request returns the next response in the sequence, wrapping back to the first after reaching the end.

Basic Cycling

{
  "stubs": [{
    "predicates": [{ "equals": { "path": "/cycle" } }],
    "responses": [
      { "is": { "statusCode": 200, "body": "Response 1" } },
      { "is": { "statusCode": 200, "body": "Response 2" } },
      { "is": { "statusCode": 200, "body": "Response 3" } }
    ]
  }]
}

Requests return responses in order:

  • Request 1 → “Response 1”
  • Request 2 → “Response 2”
  • Request 3 → “Response 3”
  • Request 4 → “Response 1” (cycles back)
  • Request 5 → “Response 2”

Use Cases

Simulating intermittent failures:

{
  "responses": [
    { "is": { "statusCode": 200, "body": "OK" } },
    { "is": { "statusCode": 200, "body": "OK" } },
    { "is": { "statusCode": 503, "body": "Service Unavailable" } }
  ]
}

Every third request fails - useful for testing retry logic.

Simulating state changes:

{
  "stubs": [{
    "predicates": [{ "equals": { "path": "/order/status" } }],
    "responses": [
      { "is": { "body": { "status": "pending" } } },
      { "is": { "body": { "status": "processing" } } },
      { "is": { "body": { "status": "shipped" } } },
      { "is": { "body": { "status": "delivered" } } }
    ]
  }]
}

Each poll returns the next order status.

Returning different data:

{
  "stubs": [{
    "predicates": [{ "equals": { "path": "/random-quote" } }],
    "responses": [
      { "is": { "body": { "quote": "Be the change you wish to see." } } },
      { "is": { "body": { "quote": "Stay hungry, stay foolish." } } },
      { "is": { "body": { "quote": "Think different." } } }
    ]
  }]
}

Cycling with Repeat Behavior

Use the repeat behavior to return the same response multiple times before advancing:

{
  "responses": [
    {
      "is": { "statusCode": 200, "body": "Success" },
      "_behaviors": { "repeat": 3 }
    },
    { "is": { "statusCode": 500, "body": "Error" } }
  ]
}

This returns “Success” three times, then “Error” once, then cycles:

  • Requests 1-3 → “Success”
  • Request 4 → “Error”
  • Requests 5-7 → “Success”
  • Request 8 → “Error”

See Behaviors for more on the repeat behavior.

Cycling State

  • Cycling state is per-stub - each stub maintains its own position
  • State resets when the imposter is deleted and recreated
  • State is not persisted - restarting Rift resets all cycling positions

Mixed Response Types

Cycling works with any response type - you can mix is, proxy, and inject:

{
  "responses": [
    { "is": { "statusCode": 200, "body": "Cached response" } },
    { "proxy": { "to": "https://api.example.com" } }
  ]
}

First request returns cached data, second proxies to real API, then cycles.


Rift Extensions: Controlled State Management

Rift-Specific Feature: The following features use Rift’s _rift.script and flowState extensions, which are not available in Mountebank.

Standard response cycling is global - all users share the same position in the cycle. This can cause unpredictable behavior in multi-user scenarios. Rift provides flow state and scripting for controlled, isolated state management.

Comparison: Cycling vs Flow State

Capability Mountebank Cycling Rift Flow State
State scope Global (all users) Per flow_id (isolated)
State persistence Lost on restart Redis backend available
Complex logic Not possible Full scripting support
Time-based rules Not possible TTL + timestamp checks
Per-user tracking Not possible Use user ID as flow_id

Per-User Retry Simulation

With standard cycling, if User A triggers the first failure, User B gets the second failure. With Rift flow state, each user gets their own retry sequence:

{
  "port": 4545,
  "protocol": "http",
  "_rift": {
    "flowState": { "backend": "inmemory", "ttlSeconds": 300, "flowIdSource": "header:X-User-Id" }
  },
  "stubs": [{
    "predicates": [{ "equals": { "path": "/api/resource" } }],
    "responses": [{
      "_rift": {
        "script": {
          "engine": "rhai",
          "code": "fn respond(ctx) { let attempts = ctx.state.incr(\"attempts\"); if attempts <= 2 { http(503, #{ error: \"Temporary failure\", attempt: attempts, user: ctx.flowId }).header(\"Retry-After\", \"1\") } else { pass() } }"
        }
      },
      "is": { "statusCode": 200, "body": "{\"status\": \"success\"}" }
    }]
  }]
}

Now each user experiences their own retry sequence:

  • User A: Fail → Fail → Success
  • User B: Fail → Fail → Success (independent of User A)

Quota Exhaustion with Reset

Rift-Only: Track quota consumption over time with manual or automatic reset.

{
  "_rift": {
    "flowState": { "backend": "inmemory", "ttlSeconds": 86400, "flowIdSource": "header:X-Api-Key" }
  },
  "stubs": [
    {
      "predicates": [{ "equals": { "path": "/api/expensive-operation" } }],
      "responses": [{
        "_rift": {
          "script": {
            "engine": "rhai",
            "code": "fn respond(ctx) { if ctx.request.header(\"X-Api-Key\") == () { return http(401, #{ error: \"API key required\" }); }; let used = ctx.state.get_or(\"quota_used\", 0); let limit = 1000; if used >= limit { http(402, #{ error: \"Quota exceeded\", used: used, limit: limit }) } else { ctx.state.set(\"quota_used\", used + 1); pass() } }"
          }
        },
        "is": { "statusCode": 200, "body": "{\"result\": \"expensive computation\"}" }
      }]
    },
    {
      "predicates": [{ "equals": { "method": "POST", "path": "/api/quota/reset" } }],
      "responses": [{
        "_rift": {
          "script": {
            "engine": "rhai",
            "code": "fn respond(ctx) { ctx.state.delete(\"quota_used\"); http(200, #{ message: \"Quota reset\" }) }"
          }
        }
      }]
    }
  ]
}

When to Use Each Approach

Scenario Use Mountebank Cycling Use Rift Flow State
Simple round-robin responses Overkill
Test sees responses in order Not needed
Multi-user with isolated state
Time-based rate limiting
Complex conditional logic
State survives restart ✅ (Redis)
Per-session behavior

See Scripting and Fault Injection for more examples.


Proxy Responses

Forward requests to real servers and optionally record for later playback.

Proxy Modes

proxyAlways - Always forward, record each response:

{
  "proxy": {
    "to": "https://api.example.com",
    "mode": "proxyAlways"
  }
}

proxyOnce - Forward first request, replay recorded response:

{
  "proxy": {
    "to": "https://api.example.com",
    "mode": "proxyOnce"
  }
}

proxyTransparent - Forward without recording:

{
  "proxy": {
    "to": "https://api.example.com",
    "mode": "proxyTransparent"
  }
}

Predicate Generators

Control how recorded stubs are created:

{
  "proxy": {
    "to": "https://api.example.com",
    "predicateGenerators": [{
      "matches": {
        "path": true,
        "method": true,
        "query": true
      }
    }]
  }
}

Adding Behaviors to Recorded Stubs

addDecorateBehavior is copied onto the stubs a proxyOnce or proxyAlways recording saves, and addWaitBehavior: true gives each saved response a wait equal to the upstream’s observed latency, so both take effect when the recorded stubs are replayed. See Proxy Mode.

{
  "proxy": {
    "to": "https://api.example.com",
    "addDecorateBehavior": "function(request, response) { response.headers['X-Proxied'] = 'true'; return response; }"
  }
}

Injection Responses

Generate dynamic responses using JavaScript. An inject response requires the server to be started with --allowInjection; without it the imposter is refused.

{
  "inject": "function(request, state, logger) { \
    var userId = request.path.split('/')[2]; \
    return { \
      statusCode: 200, \
      headers: { 'Content-Type': 'application/json' }, \
      body: JSON.stringify({ id: userId, name: 'User ' + userId }) \
    }; \
  }"
}

Request Object

Available properties in injection function:

request.method    // "GET", "POST", etc.
request.path      // "/api/users/123"
request.query     // { page: "1" }
request.headers   // { "content-type": "application/json" } - one value per name, the first sent
request.body      // Request body as a string; call JSON.parse yourself for JSON

Rift also accepts Mountebank’s current function (config) { ... } form: config.request, config.state and config.logger hold the same objects, and the request fields are copied onto config itself, which is why the legacy function (request, state, logger) form keeps working.

State Object

Persist data across requests:

function(request, state, logger) {
  // Initialize counter
  state.counter = state.counter || 0;
  state.counter++;

  return {
    statusCode: 200,
    body: { count: state.counter }
  };
}

Logger Object

Write to Rift logs:

function(request, state, logger) {
  logger.info("Processing request to " + request.path);
  return { statusCode: 200 };
}

Response Templates

EJS tags in a config file are expanded once, when the file loads, and never see a request. To put request values into a response, use the ${request.…} tokens in Request Interpolation, or the opt-in _rift.templated {{ }} grammar and the date tokens described in Response Templates.

{
  "is": {
    "statusCode": 200,
    "headers": { "Content-Type": "application/json" },
    "body": "{ \"path\": \"${request.path}\", \"method\": \"${request.method}\" }"
  }
}

Error Responses

Client Errors (4xx)

{
  "is": {
    "statusCode": 400,
    "body": { "error": "Bad Request", "message": "Invalid input" }
  }
}

{
  "is": {
    "statusCode": 401,
    "headers": { "WWW-Authenticate": "Bearer" },
    "body": { "error": "Unauthorized" }
  }
}

{
  "is": {
    "statusCode": 404,
    "body": { "error": "Not Found" }
  }
}

Server Errors (5xx)

{
  "is": {
    "statusCode": 500,
    "body": { "error": "Internal Server Error" }
  }
}

{
  "is": {
    "statusCode": 503,
    "headers": { "Retry-After": "60" },
    "body": { "error": "Service Unavailable" }
  }
}

Alternative Formats

Rift supports several alternative response formats for compatibility with various tools that generate Mountebank configurations.

proxy: null with is Response

Some tools include "proxy": null alongside an is response. This is accepted and the null proxy is ignored:

{
  "responses": [{
    "is": {
      "statusCode": 200,
      "body": "Hello"
    },
    "proxy": null
  }]
}

Flat response (no is wrapper)

Recorded and migrated mocks often put statusCode, headers, body and _mode directly on the response. Rift serves that exactly like the same fields inside is; statusCode defaults to 200.

{
  "responses": [{
    "statusCode": 404,
    "headers": { "Content-Type": "application/json" },
    "body": { "error": "not found" }
  }]
}

is wins when both are present. GET /imposters returns the response in the is form.

Combined Alternative Format

A complete example using multiple alternative formats:

{
  "responses": [{
    "behaviors": [{ "wait": 100 }],
    "is": {
      "statusCode": "201",
      "headers": { "Content-Type": "application/json" },
      "body": "{\"created\": true}"
    },
    "proxy": null
  }]
}

This example shows:

  • behaviors without underscore prefix
  • behaviors as an array
  • statusCode as a string
  • proxy: null alongside is

Best Practices

  1. Set Content-Type - Always include appropriate Content-Type header
  2. Use JSON for APIs - Return body as object for automatic serialization
  3. Include error details - Meaningful error responses help debugging
  4. Use proxy for recording - Record real API responses for reliable mocks
  5. Keep injection simple - Complex logic is harder to maintain