Skip to content

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 }
    }
  }
}

Headers go out in the order the stub writes them, as in Mountebank; a header a decorate or an inject adds goes after them, and a proxy records the origin's order.

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 ignored, as in Mountebank: the response is served as an empty response completed by the imposter's defaultResponse (else 200 with an empty body), and repeat still applies. Rift warns when the imposter loads, and rift-lint reports it as W022. The names are case-sensitive, as in Mountebank: connection_reset_by_peer is not CONNECTION_RESET_BY_PEER.

Where behaviors and _rift apply

Every response needs exactly one type: is, proxy, inject or fault (or one of Rift's two extensions, the flat form below and a _rift-only response). A config door refuses a response with two, with each response object must have only one response type, and one with none — {}, a misspelt key, only a behaviors block or only repeat — with unrecognized response type, as Mountebank does. For a plain delayed 200, write {"is": {}, "behaviors": [{"wait": 500}]}.

_behaviors/behaviors run on an is response (and the flat form below), on an inject response and on a proxy response; repeat applies to every response type — see Behaviors. A response-level _rift block is read on an is response, or the flat form, only. A _rift-only response without a script is served as is: {} with its _rift effects (fault, conditional, stateOps, templated): an empty 200, completed by defaultResponse like any is. Its behaviors other than repeat still do not run. On a proxy, inject or fault response it is kept and returned by GET /imposters/:port, but not acted on, and is reported as config_key_ignored in _rift.warnings and by rift-lint W017. _rift.script is a response type of its own: beside is, proxy, inject, fault or the flat form, a config door refuses it with each response object must have only one response type (#1729), and rift-lint reports E056.


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" } }

statusCode may be any string, in is and in defaultResponse, in the response an inject returns, and in what a behavior (decorate, shellTransform, copy) leaves on a proxy response. A copy or lookup token in it is substituted, and the result is read as Node reads a status code when Mountebank writes the response: surrounding whitespace is ignored, a fraction is truncated and 0x194 is 404. One outside 100–999 answers 500 with {"errors": [{"code": "ERR_HTTP_INVALID_STATUS_CODE", "message": "Invalid status code: <value>", "name": "RangeError"}]}, as Mountebank does, and so does a token no behavior filled in. Mountebank's error also carries the Node stack, which Rift leaves out. An empty string falls back to defaultResponse.statusCode, then 200. A numeric string is stored as the number; any other string is stored as written.

A script's statusCode need not be a string or an in-range number either. A negative or out-of-range number (-1, 70000) and a non-number such as true answer the same 500, naming the value as JavaScript prints it; a fractional number such as 404.7 is truncated to 404. A falsy one (0, false, null, NaN, "", or none at all) falls back to defaultResponse.statusCode, then 200. A proxy response a behavior leaves with a status Node refuses records nothing.

{
  "is": { "statusCode": "${code}" },
  "behaviors": [{ "copy": { "from": "path", "into": "${code}", "using": { "method": "regex", "selector": "\\d+" } } }]
}

A GET /404 against that stub answers 404, and a GET /abc answers 500. A token in defaultResponse.statusCode is never substituted, since Mountebank merges the default after the behaviors run. rift-lint warns (W023) about a string that no behavior rewrites and that is not a status code.

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": { "X-Count": 124, "X-Cache-Hit": true } } }

sends X-Count: 124 and X-Cache-Hit: true. Arrays may mix types the same way; null, objects and nested arrays are still rejected. The coerced string is also what GET /imposters prints and what a decorate or shellTransform script sees, where Mountebank keeps the number (see Differences).

A Content-Length you set is corrected to the body's real length. As in Mountebank, the value is replaced with the byte length of the body actually sent, after behaviors (decorate, copy, lookup, shellTransform) have run, and for a defaultResponse or an inject response too. A binary body counts its decoded bytes. A response with no Content-Length is left as it is. When the response also sets Transfer-Encoding, Rift drops Content-Length, because HTTP forbids sending both (see Differences); a 204 response never carries one.

A binary body may contain line breaks. Before decoding a _mode: "binary" body, Rift removes every character outside the base64 alphabet (A–Z, a–z, 0–9, +, /, =), as Mountebank does, so base64 wrapped at 76 columns decodes. A body that is still invalid after that is served as its raw text with x-rift-binary-error: true, or as a 500 under strictBehaviors.

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 7e+23 (same digits; a positive exponent is written with an explicit +) 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.

Object keys are served sorted (by byte), at every depth, not in the order the stub wrote them: {"f": 0.1, "big": 7e23} is served as {"big":7e+23,"f":0.1}, compact. Mountebank serves JSON.stringify(body, null, 4), which keeps the order the stub declared and indents by four spaces, so a client or snapshot that compares the body text sees a different document from each engine; one that parses the JSON sees the same one. This is a deliberate divergence: Rift's JSON values are key-sorted throughout, and the stable stub identity used by incremental reload relies on that. When the exact bytes matter, write the body as a string ("body": "{\"f\":0.1,\"big\":7e23}"), which is served verbatim.

An object body an inject returns or a decorate leaves is served as JavaScript's JSON.stringify writes it: compact, in the script's key order, with a lone surrogate escaped as \udXXX. One that JSON.stringify refuses (a cycle, a BigInt), or one nested more than 128 levels deep, answers 500.

{
  "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. A body that does not decode (or is not a string) is served as its raw text with x-rift-binary-error: true, or as a 500 under strictBehaviors — on an is response and a defaultResponse alike — and rift-lint reports it as W015.


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; a query predicate matches each value instead (Repeated keys)
${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

Response cycling in a Rift stub with responses A (repeat 2), B and C: each request is answered by the response at the head of the queue, which moves to the tail once it has been served repeat times, so requests get A, A, B, C, A. Replacing the stub by id keeps the cycle; replacing it at its index resets it to A. Captions list the other resets (PUT /imposters, DELETE savedProxyResponses) and what keeps the cycle (replace by id, a reload that leaves the stub unchanged).

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.

A proxyAlways recording appended to a stub mid-cycle joins the end of its current cycle; see Where recordings go.

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, when the stub is replaced at its index (PUT /imposters/:port/stubs/:index, even with identical content), when the whole stub list is replaced (PUT /imposters/:port/stubs), for every imposter in a PUT /imposters body (unless ?reconcile=true), and on DELETE /imposters/{port}/savedProxyResponses, which restarts every stub's cycle, as in Mountebank
  • State is kept when another stub is inserted, deleted or replaced, when the stub is moved, when it is replaced by id (PUT …/stubs/by-id/:id, a Rift extension), and when a reload leaves the stub unchanged
  • State is persisted with --datadir: each stub's position is saved to <port>.cursors and resumed on restart, as in Mountebank. It is written every second and on a graceful stop (SIGTERM/SIGINT, rift stop), so a crash can lose up to a second of advances. Without --datadir, and for --configfile imposters, a restart starts every cycle over

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", "mode": "proxyTransparent" } }
  ]
}

First request returns cached data, second proxies to real API, then cycles. (Without proxyTransparent the proxy turn is proxyOnce and replays its first response on later cycles.)


Rift Extensions: Controlled State Management

Rift extension

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 Lost on restart (in-memory flow store)
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": { "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 { http(200, #{ 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 extension

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

{
  "_rift": {
    "flowState": { "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); http(200, #{ 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 ❌ ❌ (in-memory flow store; shared state is a rift-cluster deployment)
Per-session behavior ❌ ✅

See Scripting and Fault Injection for more examples.

Conditional GET

Rift extension

Rift-Only: _rift.conditional on an is response.

A client that polls with validators (a CDN-hosted datafile, a feature-flag SDK) expects ETag / Last-Modified on the response and a 304 Not Modified when it already holds the current copy. _rift.conditional gives a stub both without a second stub or a hand-picked validator:

"_rift": { "conditional": true }
"_rift": { "conditional": { "etag": true, "lastModified": "load" } }
"_rift": { "conditional": { "etag": false, "lastModified": "Sat, 03 Oct 2026 12:00:00 GMT" } }

true is the second form. false or no block turns it off.

  • etag (default true): a strong ETag, "fnv1a64-<16 hex digits>", the FNV-1a 64 hash of the bytes served, after _rift.templated, ${request.*} substitution, behaviors and date tokens. A templated or behavior-altered body gets its own tag per request. The tag is the same on every process and node for the same bytes.
  • lastModified (default "load"): "load" is the time the stub was loaded: when it was created, or last changed by the admin API (a replace that leaves the stub byte-identical keeps it) or a reload. A reload that leaves the stub, and everything ahead of it, unchanged keeps it; one that rewrites most of an imposter's stubs restarts the imposter, which re-stamps them all, as does a restart. Otherwise an HTTP-date in the Sat, 03 Oct 2026 12:00:00 GMT form, served verbatim. Any other string is refused when the config is loaded.

    "load" stamps are whole seconds, and a change made through the admin API or a reload in the same second as the previous one is stamped one second after it — including across a reload that restarts the imposter and a delete and re-create on the same port (not across a process restart). A client that revalidates with If-Modified-Since alone (the Optimizely SDKs do) therefore always sees a change. The cost is that rapid changes can stamp Last-Modified up to a second per change ahead of Date.

    A stub is also re-stamped when it may now answer requests another stub answered: when a stub ahead of it is deleted or moved, or has its predicates (or space / scenario gate) changed — through the admin API, PUT /imposters/{port}/stubs or a reload. The first stub that matches wins, so otherwise a client holding the removed stub's newer stamp would be told 304 for a different body. Stubs ahead of the change keep their stamps; deleting the last stub, or changing only a stub's responses, re-stamps nothing else.

    A scenario transition changes which stub answers with no stub changing, so it counts too: the served Last-Modified is the later of the stub's own stamp and the last transition of any scenario gating that stub or a stub ahead of it — through a newScenarioState, the scenarios admin API, a reset, a space teardown, or the raw …/flow-state routes (a scenario's state is the key named after it, so setting or deleting that key, or clearing the flow, is a transition too). Writing the state a scenario already has is not a transition, and only scenarios gating a stub at or ahead of a _rift.conditional stub are tracked. A transition in one flow moves the stamp for every flow of that scenario (one extra 200 for the others), and it is per process: a transition made by another node does not move this node's stamp. A response that writes a scenario's key counts too: _rift.stateOps and a script's ctx.state, including a TTL of zero, which deletes at once (#1388). The one change it cannot see is a positive TTL running out on a scenario's key, which returns the scenario to Started with no write; revalidate with If-None-Match (the ETag follows the body) if your scenarios expire that way.

On a script response (_rift.script), or behind a non-GET method predicate, it never fires; rift-lint W019 says so.

It applies only to a GET or HEAD that the stub answers with a 2xx; any other request or status is served unchanged, without validators. The validators replace any ETag or Last-Modified the response declares in headers. The response is 304 when:

  1. the request has If-None-Match and one of its tags matches the ETag (weak comparison, so W/"…" matches; a list or * is accepted). If-Modified-Since is then ignored, whatever it says; otherwise
  2. the request has If-Modified-Since, it parses as an HTTP-date, and Last-Modified is not later than it (to the second).

The 304 carries ETag, Last-Modified, Cache-Control, Vary, Expires and Content-Location from the configured response when present, and x-rift-imposter: true, with no body, Content-Type or Content-Length. allowCORS adds its headers on a preflight, as on any other response.

The Optimizely template is a worked example: its datafile CDN is one is stub with _rift.conditional: true.

What a 304 still does:

  • It consumes a cycle position. The response is chosen before the request's validators are read, so with several responses a 304 for one of them moves the cycle on like a 200 would.
  • It runs wait and every other behavior: the ETag is computed over the body the behaviors produce. A _rift.fault still fires instead, and _rift.stateOps still run.
  • It is journaled as 304, the status the client received, so verify can assert that a client polled with validators.

Proxy Responses

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

Proxy Modes

mode defaults to proxyOnce, as in Mountebank; an unknown value is refused. See Proxy Mode for what each mode records.

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.

Creating the imposter (or adding the stub) only parses the script, never runs it: a syntax error is refused with a 400 (Syntax error: …), while a script that parses but throws when it is called is accepted and fails per request. Before 0.18.0 the admin API ran the script at creation, so a top-level expression that never finished hung the request.

{
  "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", q: ["1", "2"] } - keys in the order the client sent them; a repeated key is an array, as in Mountebank
request.headers   // { "Content-Type": "application/json" } - in the order the client sent them, a repeated header an array, names Title-Cased; a lookup in any case finds the header ([why](differences.md#request-header-names))
request.body      // Request body as a string; call JSON.parse yourself for JSON. `""` when the request has no body, as in Mountebank
request.requestFrom // "127.0.0.1:53124" - the client's address:port
request.ip        // "127.0.0.1" - the client's address
request.form      // { a: "1" } - the parsed body, only for a non-empty application/x-www-form-urlencoded body; undefined otherwise. A repeated key is an array

requestFrom and ip are the values the requestFrom and ip predicates match. On the default bind address an IPv4 client is 127.0.0.1 in Rift and ::ffff:127.0.0.1 in Mountebank — see Client address. A query key, form key or header the client repeated reaches the script as an array (?q=1&q=2 gives ["1", "2"]), as in Mountebank; one sent once is a string. Releases up to 0.23.0 joined a repeated key into one string ("1,2") and showed only the first value of a repeated header, so a script that split on commas should test Array.isArray instead. The same request object reaches a predicate inject, a proxy's predicateGenerators inject, decorate and shellTransform's MB_REQUEST.

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 };
}

Asynchronous injection

The answer is the first of two things, as in Mountebank: a value the function returns that is not undefined, or what it passes to its callback (the fourth argument, also config.callback). The callback may be called later, from a .then or a timer. A returned promise, such as an async function's, is awaited, and a rejected one fails the request like a throw:

async config => {
  await new Promise(resolve => setTimeout(resolve, 20));
  return { statusCode: 200, body: 'after 20ms' };
}
(config, state, logger, callback) => {
  setTimeout(() => callback({ body: 'late' }), 20);
}

Waiting is bounded by the imposter's script timeout (_rift.scriptEngine.timeoutMs, 5 seconds by default): an inject that has not answered by then, or whose next timer is due after it, fails with the script timeout (504). A function that returns undefined and whose callback is never called once its jobs and timers have run fails with Inject function must return an object; Mountebank never answers that request.

While an inject waits, other requests go on, as in Mountebank's event loop: a second request to the same imposter runs while the first awaits its timer, and a waiting inject holds no script worker, so sleeping injects never hold up another imposter's scripts. An inject whose client stops waiting (its script timeout, a delete of the imposter) is dropped, and its timers never fire.

The imposter's state is one object shared by all its runs in flight, as Mountebank's imposterState is: a write is visible to a run that is still waiting, at its next await, and code between two awaits never interleaves with another run, so state.n = (state.n || 0) + 1 loses no update. A write made before the function throws is kept.

What a script can use

inject, predicate inject, decorate and wait functions run standard JavaScript in Rift's script engine, not in Node:

  • console.log, info, warn, error and debug write to Rift's log at target rift::script, as logger does (log at info), never to standard output.
  • Promises, async and await.
  • setTimeout, clearTimeout, setInterval and clearInterval, with Node's delay rule (a delay that is not a number from 1 to 2^31 - 1 is 1 ms). A timer runs only while the script's answer is awaited, as above, or an async decorate's promise is.
  • require, for a CommonJS or JSON file only (differences).

There is no process (so no process.env), no Buffer and no Node core module such as http or fs; see differences.


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. Mountebank refuses this form with unrecognized response type: it is a Rift extension (see Differences).

{
  "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.

A _rift block beside the flat form applies to it as beside is (#1722), so {"statusCode": 503, "_rift": {"fault": {...}}} answers 503 with the fault's effect and runs its behaviors. Beside _rift.script the flat form is refused, as a second response type (#1729).

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