Imposters
An imposter is a mock server that listens on a specific port and responds to requests based on configured stubs.
Creating an Imposter
Basic HTTP Imposter
curl -X POST http://localhost:2525/imposters \
-H "Content-Type: application/json" \
-d '{
"port": 4545,
"protocol": "http",
"name": "My Service Mock",
"stubs": [{
"responses": [{
"is": { "statusCode": 200, "body": "Hello" }
}]
}]
}'
HTTPS Imposter
curl -X POST http://localhost:2525/imposters \
-H "Content-Type: application/json" \
-d '{
"port": 4546,
"protocol": "https",
"name": "Secure Service Mock",
"key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
"cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
"stubs": [{
"responses": [{
"is": { "statusCode": 200, "body": "Secure Hello" }
}]
}]
}'
Omit key and cert and Rift serves a generated self-signed certificate. Certificates, mutual TLS and the related fields are covered in TLS/HTTPS.
Imposter Configuration
| Field | Type | Required | Description |
|---|---|---|---|
port | number | No | Port to listen on (auto-assigned if omitted, null or 0) |
host | string | No | Address to bind (default 0.0.0.0; use 127.0.0.1 for local-only) |
protocol | string | No | http or https (default: http) |
name | string | No | Human-readable name |
stubs | array | No | Request/response mappings |
defaultResponse | object | No | Response when no stub matches |
defaultForward | string | No | Rift extension: forward an unmatched request to this base URL (takes precedence over defaultResponse) |
recordRequests | boolean | No | Store requests for verification (default false) |
recordMatches | boolean | No | Record which stub matched each request (default false) |
allowCORS | boolean | No | Enable CORS headers and handle preflight requests |
strictBehaviors | boolean | No | Rift extension: a failing behavior returns 500 instead of a fallback (see Behaviors) |
service_name / serviceName | string | No | Service identifier for documentation |
service_info / serviceInfo | object | No | Additional service metadata |
_rift | object | No | Rift extensions (flow state, scripts, faults, metrics) |
key | string | No | PEM private key (https only; paired with cert) |
cert | string | No | PEM certificate (https only; paired with key) |
mutualAuth | boolean | No | Request and require a client certificate (https only; true on http is refused) |
rejectUnauthorized | boolean | No | Validate the client certificate against ca; requires ca |
ca | string or array | No | PEM trust anchor(s) client certificates must chain to |
The five TLS fields are described in full in TLS/HTTPS. Note that mutualAuth is stricter than Mountebank: a client that presents no certificate fails the handshake.
HTTP/2 and h2c
Rift auto-negotiates the HTTP version — you don’t configure it per imposter:
- HTTPS imposters advertise
h2andhttp/1.1via TLS ALPN, so an HTTP/2-capable client gets HTTP/2 and everything else falls back to HTTP/1.1. - Plain HTTP imposters accept h2c (cleartext HTTP/2 via prior-knowledge) alongside HTTP/1 — the listener detects the HTTP/2 preface and upgrades automatically.
This is on by default and backward-compatible with HTTP/1 clients. Three things force HTTP/1-only:
- an imposter that uses any TCP fault (
_rift.fault.tcpor a top-levelfault), because a connection-level abort is incompatible with HTTP/2 multiplexing; - an imposter with a
_rift.scriptresponse, since a script may callreset()at runtime; and - setting the
RIFT_DISABLE_HTTP2environment variable (truthy:1/true/yes/on), which forces every listener — HTTP and HTTPS, imposter, admin, and metrics — down to HTTP/1.
On an HTTPS imposter this decision governs the ALPN offer as well as what is served: such an imposter advertises only http/1.1 during the TLS handshake rather than offering h2 it would not speak. A client offering both protocols therefore settles on http/1.1; a client that offers only h2 (a gRPC-style client, say) is refused at the handshake with no_application_protocol rather than being allowed to commit to a protocol the imposter cannot serve. The decision is made per connection against the imposter’s current stubs, so adding or removing a fault or script stub through the admin API changes what the next connection is offered — an existing connection is unaffected.
Auto-Port Assignment
If you omit the port field (or set it to null or 0), Rift assigns the first free port from the dynamic range (49152-65535). This holds at every door — POST /imposters, PUT /imposters, --configfile, reload and the C-ABI — so a document may hold several port-less imposters:
curl -X POST http://localhost:2525/imposters \
-H "Content-Type: application/json" \
-d '{
"protocol": "http",
"stubs": [{
"responses": [{ "is": { "statusCode": 200 } }]
}]
}'
# Response includes the assigned port:
{
"port": 49152,
"protocol": "http",
...
}
Stubs
Each stub contains predicates (matching rules) and responses:
{
"stubs": [
{
"predicates": [
{ "equals": { "method": "GET", "path": "/api/users" } }
],
"responses": [
{ "is": { "statusCode": 200, "body": "[]" } }
]
}
]
}
Stub Configuration
| Field | Type | Required | Description |
|---|---|---|---|
id | string | No | Unique identifier (Rift extension) |
predicates | array | No | Conditions to match requests (rules is accepted as an alias) |
responses | array | Yes | Responses to return |
scenarioName | string | No | Identifier for test scenarios |
requiredScenarioState / newScenarioState | string | No | Rift extension: scenario state gate and transition (see Scenarios) |
space | string | No | Rift extension: only eligible for requests whose flow id equals this (see Spaces) |
routePattern | string | No | Rift extension: route such as /users/:id that fills request.pathParams |
delayRange | array | No | Stub-level latency [{"min": 50, "max": 100}], applied as a wait on each response |
recordedFrom | string | No | Upstream a recorded stub came from (written by proxy recording) |
_verify | object | No | Ignored by the engine; read by rift-verify |
The id field is a Rift extension that allows you to identify stubs by name rather than index:
{
"stubs": [{
"id": "get-user-success",
"predicates": [{ "equals": { "path": "/users/123" } }],
"responses": [{ "is": { "statusCode": 200, "body": "{\"id\": 123}" } }]
}]
}
The scenarioName field is useful for organizing stubs into logical groups for testing:
{
"stubs": [{
"scenarioName": "UserService-GetUser-Success",
"predicates": [{ "equals": { "path": "/users/123" } }],
"responses": [{ "is": { "statusCode": 200, "body": "{\"id\": 123}" } }]
}]
}
Multiple Responses (Round-Robin)
When a stub has multiple responses, they cycle through:
{
"stubs": [{
"predicates": [{ "equals": { "path": "/flip" } }],
"responses": [
{ "is": { "body": "heads" } },
{ "is": { "body": "tails" } }
]
}]
}
First request returns “heads”, second returns “tails”, third returns “heads”, etc.
Default Response
Configure a fallback response when no stub matches:
{
"port": 4545,
"protocol": "http",
"defaultResponse": {
"statusCode": 404,
"headers": { "Content-Type": "application/json" },
"body": { "error": "Not Found" }
},
"stubs": [...]
}
defaultResponse honours "_mode": "binary", with the same contract as a stub’s is body: a base64 body is decoded and served as bytes. If it does not decode, the raw text is served with x-rift-binary-error: true alongside x-rift-default-response: true, or a 500 under strictBehaviors — see behavior failures. A non-string body in binary mode can never decode, since it is serialized to JSON text first. rift-lint reports both cases as W015.
Recording Requests
Enable request recording for verification in tests:
{
"port": 4545,
"protocol": "http",
"recordRequests": true,
"stubs": [...]
}
Retrieve recorded requests:
curl http://localhost:2525/imposters/4545
# Response includes:
{
"requests": [
{
"requestFrom": "127.0.0.1:53412",
"method": "GET",
"path": "/api/users",
"query": {},
"headers": {...},
"timestamp": "2024-01-15T10:30:00.000Z",
"status": 200,
"latencyMs": 0
}
]
}
body is omitted when the request had none. status and latencyMs are the status that went back and how long the imposter took to produce it, in whole milliseconds. Both are absent, never 0, when the outcome was not observed (for example an X-Rift-Debug request, or one that errored before a response existed); a present latencyMs of 0 is an ordinary sub-millisecond answer. matchOutcome (which stub matched, or why none did) may also appear.
Each header name maps to the list of values the client sent, in order, so a header sent twice is recorded as {"X-Test": ["first", "second"]} rather than collapsing to one value.
Header names are case-insensitive, so a document that spells one name two ways describes one header, not two. Rift merges such entries when it parses a multi-valued header object — a recorded request, a stub’s headers, a flat response, an intercept rule’s serve action. A document like
{ "content-type": "text/plain", "Content-Type": "application/json" }
therefore loads, serves and lists back as a single header carrying both values. The same applies to a name repeated with identical spelling, which previously kept only its last value.
Which spelling survives, and the resulting order of the values, is deliberately unspecified. It is deterministic — the same document always gives the same answer, which is the bug this fixed — but it depends on how the document reached Rift, and one --configfile document can go either way depending only on whether it uses the {"imposters": [...]} wrapper or a bare array. Write the name once and the question never arises; that is the supported shape, and it is what every document that has ever been valid already does. A document that spells each name once is unaffected in every respect, and the spelling you wrote is the spelling Rift serves — nothing is lowercased or title-cased.
The check is UTF-8 validity, not ASCII (#1048): a header carrying non-ASCII UTF-8 is recorded byte-exact, on both the request and the response side.
A header value that is not valid UTF-8 is dropped rather than recorded: the journal never claims the client sent an empty string it did not send. A header name whose only value was undecodable is absent from headers entirely. Unlike a binary request body (below), a header has no _mode slot on the wire to carry an encoded form, so dropping — with a warning logged server-side — is the honest representation.
The same rule applies in the other direction, to a proxy response relayed from a real upstream (#1041): an upstream header value that is not valid UTF-8 is dropped, with a server-side warning, rather than relayed as an empty string. It reaches neither the client nor the stub that proxyOnce/proxyAlways records — which matters most for the stub, since a blanked value there would be served on every later request, long after the upstream was out of the picture. Note that this check is on UTF-8 validity, not on ASCII: an upstream header such as Content-Disposition: attachment; filename="résumé.pdf" relays, records and replays byte-exact.
Single-valued header objects reject a repeated name
Two header objects hold one value per name rather than a list: proxy.injectHeaders and _rift.fault.error.headers. They do the opposite of the merge above — a name given twice, in any casing, is rejected, with a message naming both spellings (#1050):
POST /impostersanswers400--configfilefails at startup rather than booting with the imposter silently missing- the FFI’s
rift_apply_configreturns the same message throughrift_last_error
There is no lossless merge for one slot and two different values, and silently picking a winner is exactly what the merge above exists to avoid. Before this, both spellings survived and both header lines went out, ordered differently from one process to the next. Write the name once.
One wrinkle worth knowing: a name repeated with identical spelling ({"x": "a", "x": "a"}) is caught by the engine only when the document is read as text. Through --configfile’s {"imposters": [...]} wrapper the JSON parser has already collapsed it before Rift sees it, so it loads. A name repeated in different casing is caught on every path.
rift-lint closes that asymmetry ahead of time: E044 reads the raw document text, so it reports a byte-identical repeated name in these two fields before the document reaches any ingestion path. It is scoped to them deliberately — in is.headers a repeated name is merged into two header lines rather than rejected, which is how a stub sends two Set-Cookies.
Binary Request Bodies
A request body that is not valid UTF-8 (protobuf, gzip, an image upload) cannot be recorded as text without destroying it. Such a body is recorded base64-encoded and marked with _mode: "binary", mirroring how binary response bodies are represented:
{
"requests": [
{
"method": "POST",
"path": "/api/upload",
"body": "iVBORw0KGgoAAAANSUhEUg==",
"_mode": "binary",
"timestamp": "2024-01-15T10:30:00.000Z"
}
]
}
The body is standard base64 (with padding). _mode is absent for a normal text/JSON body, so recordings of text traffic are unchanged — check for it rather than assuming it is present.
Scripts see the same distinction: ctx.request.body carries the base64 string and ctx.request.isBinary is true. Where rift cannot determine the encoding (the decorate and predicate-inject paths do not carry it), isBinary is absent rather than false — so a script can tell “text” apart from “unknown” instead of being told something untrue.
Managing Imposters
List All Imposters
curl http://localhost:2525/imposters
# Response:
{
"imposters": [
{ "port": 4545, "protocol": "http", "name": "User Service" },
{ "port": 4546, "protocol": "https", "name": "Payment Service" }
]
}
Get Imposter Details
curl http://localhost:2525/imposters/4545
# Response includes full configuration and recorded requests
Delete Single Imposter
curl -X DELETE http://localhost:2525/imposters/4545
Delete All Imposters
curl -X DELETE http://localhost:2525/imposters
Loading from Configuration File
JSON Format
Create imposters.json:
{
"imposters": [
{
"port": 4545,
"protocol": "http",
"stubs": [...]
},
{
"port": 4546,
"protocol": "http",
"stubs": [...]
}
]
}
Load on startup:
docker run -v $(pwd)/imposters.json:/imposters.json \
zainalpour/rift-proxy:latest --configfile /imposters.json
EJS Templates
A --configfile or file: source is preprocessed once, when it loads, and can read the process environment. --datadir files are not preprocessed.
{
"imposters": [
{
"port": <%= process.env.PORT || '4545' %>,
"protocol": "http",
"stubs": [...]
}
]
}
Only two expression forms are evaluated: <%= process.env.VAR %> and <%= process.env.VAR || 'default' %>, with the default in quotes. The value is pasted in as text, so leave the tag outside the JSON quotes for a number such as port; inside quotes it becomes a string, which the engine refuses for a port. There are no template variables.
A variable that is unset renders empty when the tag has no default, and one whose value is not valid Unicode renders the default or empty. Either way the engine logs a warning naming the variable and the tag’s line when the file loads, and if the rendered file then fails to parse, the error names the variable too. Give the tag a default to avoid it for an unset variable; a value that is not valid Unicode is reported either way.
Any other tag fails the load with an error naming the tag and its line: another <%= … %> expression, a <% … %> statement other than include, a <%- … %> output tag other than stringify, a <%# … %> comment, or a <% with no closing %>. An included file is checked the same way, but may not include another file. A stringified file may not include or stringify another file either. A Mountebank template that relies on them has to be rewritten, not loaded with parts of it missing. If a <% is meant literally, for example in a response body that serves an EJS page, load the file with --no-parse. A document fetched from an https: source is always preprocessed, so it cannot carry a literal <%.
<% include 'path' %> inlines another file, and <%- stringify('path') %> inlines a file’s contents escaped for use inside a JSON string. A stringified file is rendered first, so process.env tags in it are substituted. --no-parse turns preprocessing off; see the CLI reference. rift-lint renders a templated file the same way before it checks it; see Linting.
Stub Matching Behavior
First-Match-Wins
Stubs are evaluated in order. The first stub whose predicates match is used:
{
"stubs": [
{
"predicates": [{ "startsWith": { "path": "/api" } }],
"responses": [{ "is": { "body": "general" } }]
},
{
"predicates": [{ "equals": { "path": "/api/users" } }],
"responses": [{ "is": { "body": "specific" } }]
}
]
}
In this example, /api/users returns “general” because the first stub matches. To get “specific”, swap the stub order.
Empty Predicates (Catch-All)
A stub with empty predicates matches all requests:
{
"stubs": [
{ "predicates": [], "responses": [{ "is": { "body": "catch all" } }] }
]
}
Warning: A catch-all stub shadows all subsequent stubs. Place catch-all stubs last.
Index-Based Operations
Stub indexes shift when stubs are added or removed:
Before: [Stub0, Stub1, Stub2] (indexes 0, 1, 2)
Delete index 0:
After: [Stub1, Stub2] (indexes 0, 1)
Rift Stub Analysis (Rift Extension)
Rift provides optional warnings for common stub configuration issues. These warnings appear in the API response under _rift.warnings:
curl http://localhost:2525/imposters/4545
# Response includes:
{
"port": 4545,
"stubs": [...],
"_rift": {
"warnings": [
{
"warningType": "catch_all_not_last",
"message": "Catch-all stub at index 0 will shadow 2 stub(s) after it",
"stubIndex": 0
}
]
}
}
Warning Types
| Type | Description |
|---|---|
duplicate_id | Multiple stubs have the same ID |
exact_duplicate | Stub predicates are identical to another stub |
potentially_shadowed | Stub may be unreachable due to earlier stub |
catch_all | Stub with empty predicates matches all requests |
catch_all_not_last | Catch-all stub is not at the end of the list |
state_ops_never_runs | _rift.stateOps is on a response shape that never runs it |
truncated | More warnings were produced than are retained |
See Stub Analysis for details.
Note: Mountebank does NOT provide overlap detection. These warnings are a Rift extension.
Imposter Error Responses
These are errors served on the imposter port — distinct from the admin API’s error responses (see API Reference). Every one of them serves the same JSON envelope as the admin plane, with Content-Type: application/json:
{ "errors": [ { "code": "...", "type": "...", "message": "..." } ] }
Branch on type — it is always a stable symbolic slug. code is legacy: the HTTP status as a string on most doors, a slug on a few, frozen for backward compatibility. See Error Responses for the full type list.
| Status | When |
|---|---|
400 | The request body could not be read to completion — a client transmission failure mid-stream. The underlying cause is logged server-side. |
413 | The request body exceeded the configured size cap. |
500 | An internal failure while building the response — for example a stub, inject, or upstream header value that is not a legal HTTP header. |
504 | A script exceeded its deadline. See Scripting and Debug Mode. |
The 400/413 split matters when you are debugging a flaky client: 413 means the body was too big, 400 means the connection failed to deliver the body it promised. Earlier releases answered 413 for both, which reported every mid-stream network failure as “body too large” (#694).
Two doors deliberately do not use the envelope: the Unknown fault diagnostic (a plain-text fault-injection marker) and the last-resort internal error emitted when the envelope itself cannot be constructed.
Best Practices
- Use meaningful names - Makes debugging easier
- Order stubs specifically - More specific predicates first
- Enable recording in tests - Verify expected requests
- Use default responses - Clear error messages for unmatched requests
- Separate imposters by service - One imposter per external dependency
- Place catch-all stubs last - Avoid accidentally shadowing specific stubs
- Use stub IDs (Rift) - Easier to track and manage stubs by name