Skip to content

Proxy Mode

Proxy mode forwards requests to real servers and optionally records responses for playback. This is useful for creating mocks from real API behavior.


Basic Proxy

Forward every request to a backend server:

{
  "port": 4545,
  "protocol": "http",
  "stubs": [{
    "responses": [{
      "proxy": {
        "to": "https://api.example.com",
        "mode": "proxyTransparent"
      }
    }]
  }]
}

Without "mode": "proxyTransparent" the proxy is proxyOnce: it forwards the first request for each method, path, query and body, then replays that response (see Proxy Modes).

The proxy never follows redirects, as in Mountebank: a 30x from the origin is returned to the caller, and recorded, as-is, Location included.

to is an origin

to names where to send the request, not what to send. The upstream receives the request's own path and query; only the scheme, host and port are taken from to, as in Mountebank.

  • An http or https to with a path, such as "http://api.example.com/v1", is refused: 400 (proxy.to must not contain a path '/v1') from POST/PUT /imposters and the stub routes, a startup error from --configfile or --datadir, as Mountebank refuses it. A to that is not a URL is refused the same way. rift-lint reports both as E057.
  • A trailing /, a ?query or a #fragment in to is accepted and ignored: "to": "http://api.example.com/" sends GET /foo?a=1 for a request to /foo?a=1. So is userinfo (http://user:pw@host): no Authorization header is made from it; use injectHeaders to send one.
  • To send every request under a base path, use pathRewrite with an empty from: "pathRewrite": {"from": "", "to": "/v1"} sends /foo as /v1/foo.

Proxy Modes

Rift's three proxy modes, each sent GET /x three times. proxyOnce, also the default when mode is omitted, forwards the first request, records the answer as a new stub in front of the proxy stub, and replays it, so upstream is called once. proxyAlways forwards every request and records behind the proxy, appending each answer to one recorded stub that answers only once the proxy stops matching. proxyTransparent forwards every request and records nothing.

What a proxy records depends on whether it has predicateGenerators (or addWaitBehavior / addDecorateBehavior):

  • With generators, the proxy records each response as a new stub in the imposter, which then answers later matching requests. Under proxyOnce a request that matches no recorded stub is forwarded and recorded as another stub, as in Mountebank — a second body under a body generator, a second X-Tenant under a headers generator. This holds even when that request was already recorded in front of another proxy stub, or its recording sits behind the proxy stub that answered: it is forwarded and recorded in front of the stub that answered. Concurrent first requests for the same generated predicates record once. The recorded stub is the recording: delete it, or replace the stubs without it, and the next matching request is forwarded and recorded again.
  • Without generators, the imposter's stubs never change. "mode": "proxyOnce" still replays: it keeps the first response for each request in an internal store, so the upstream sees each request once. A request is identified by its method, path, query string and request body; headers are not part of the key, so use predicateGenerators (for example {"matches": {"headers": {"X-Tenant": true}}}) to key on one. DELETE /imposters/{port}/savedProxyResponses clears that store, and the recorded responses described below. Its recordings are not stubs, so they do not appear in GET /imposters/{port} or an export; use predicateGenerators to record exportable stubs (see Differences from Mountebank). proxyAlways and proxyTransparent forward every request.
  • proxyTransparent never records, with or without generators.

If you leave mode out, it is proxyOnce, as in Mountebank: with generators it records stubs, and without them it replays from the internal store. Set "mode": "proxyTransparent" to forward every request. The value is read case-insensitively (proxyalways works; rift-lint W007 suggests the canonical spelling). An unknown value such as "proxyEverything" is refused — 400 from POST /imposters and the stub routes, a startup error from --configfile or --datadir — and rift-lint reports it as E052. This diverges from Mountebank, which treats an unknown mode as proxyOnce.

proxyAlways

Always forward requests; record a new stub for each unique request:

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

Use when: Building a comprehensive mock from varied requests.

proxyOnce

Forward the first request, then replay the recorded response:

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

Use when: Recording a fixed set of responses for offline testing.

Where recordings go

As in Mountebank, a recorded stub is placed relative to the proxy stub that answered the request:

  • proxyOnce: in front of that proxy stub, so the recording answers the next matching request.
  • proxyAlways: its response is added to the first stub after that proxy stub with the same predicates. With none, the recording goes at the end of the stub list, behind any stub written after the proxy. It answers only once the proxy stops matching, for example after mb replay or when the proxy stub is removed.

    A response added to an existing stub joins the end of that stub's current cycle, as in Mountebank's rotating response queue: if the stub has served A from [A, B, C], the new response U is served after B, C and A, not straight after C. The stub's responses list still shows append order ([A, B, C, U]). Added in the middle of a repeat run, the response splits that run for good: [A×2, B] that has served one A cycles A B A U from then on. Any later edit of the stub returns it to list order, from the response that was due next.

Each recorded response carries _proxyResponseTime in its is, the upstream round trip in milliseconds, as in Mountebank. It is the mark DELETE /imposters/{port}/savedProxyResponses reads: every response carrying a numeric one is removed, a stub left empty goes, and every remaining stub starts its cycle over. ?replayable=true and rift save leave it out, so a replayed export holds no saved proxy responses.

A proxyAlways recording made without predicateGenerators (with addWaitBehavior or addDecorateBehavior) has no predicates. It therefore merges into the first stub after the proxy that has none, including a catch-all stub you wrote, as in Mountebank.

proxyTransparent

Forward without recording (pure reverse proxy):

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

Use when: Acting as a transparent proxy without mocking.


Predicate Generators

Control how requests are matched when generating stubs:

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

Available Fields

Field Description
method Match HTTP method
path Match request path
query Match query parameters
headers Match request headers
body Match request body

Generator Keys

Key Description
matches Which request fields the generated predicate captures (table above). An object whose keys are request fields: method, path, query, headers, body, requestFrom, ip, form. requestFrom is the client's address:port and ip its address, as the matcher reads them; a recorded requestFrom includes the client's port, so it matches that connection only, as in Mountebank. An imposter with no host listens dual-stack in both engines, so both report an IPv4 client as ::ffff:127.0.0.1 and record that; with "host": "0.0.0.0" the client is 127.0.0.1. form is the urlencoded body, recorded like query; a request without a non-empty urlencoded body records no form predicate (Mountebank records deepEquals: {}, which also constrains nothing). An IPv6 client's requestFrom is written [::1]:50000 in Rift and ::1:50000 in Mountebank, and under "predicateOperator": "matches" the brackets read as a character class, so such a recording does not match its own client
predicateOperator The operator of every generated predicate: equals, deepEquals, contains, startsWith, endsWith, matches or exists; omitted, see What Gets Recorded. Any other value is refused (400, or a startup error; rift-lint E053). Mountebank admits it and then answers 500 to every request after the first recording. Mountebank 2.9.5 refuses every generator with "predicateOperator": "exists" when the imposter is created (an internal error with no message), although its docs describe it; Rift records {"exists": …} as documented
caseSensitive A boolean, copied onto each generated predicate when set (see Case Sensitivity)
except A string, copied onto each generated predicate; the recorded values stay whole and the matcher strips the regex from both sides (see except)
jsonpath {"selector": "$.id"}: capture only what the selector selects from the body, and scope the generated predicate with the same selector
xpath {"selector": "//a:id", "ns": {"a": "urn:a"}}: as jsonpath, for an XML body
ignore Keys to leave out of a captured object field: {"query": "ts"}, {"query": ["ts", "nonce"]}, {"headers": "X-Request-Id"}. An object form ({"query": {"a": …}}) has no effect, since captured values are strings, as in Mountebank for scalar values
inject A JavaScript function that returns the predicates (see Generation Failures)

With jsonpath or xpath, the captured body is the selected value: one match is a string, several are an array (the recorded stub then matches a request that selects all of them), and nothing selected is "". A selector that does not compile is not carried and the whole body is captured, as without one. ignore applies to the object-valued fields (query, headers, form); it has no effect on a string body. A key not in this table is accepted, ignored when recording, and reported as config_key_ignored and by rift-lint W020.

An entry the recorder cannot read is refused (400, or a startup error; rift-lint E055): one that is not an object, has neither matches nor inject, has a non-string inject, a matches that is not an object or names a key that is not a request field, a non-boolean caseSensitive or a non-string except. Rift used to skip such an entry and record the rest, which was wider than the generators asked for, or a stub matching every request when no entry could be read. Mountebank refuses the first three when the imposter is created. It admits the rest: a matches that is not an object, or names a key that is not a request field, records a stub that matches every request, and a non-boolean caseSensitive or non-string except is copied onto the recorded predicates. caseSensitive and except are not checked on an inject generator, which does not read them.

{
  "predicateGenerators": [{
    "matches": { "path": true, "body": true, "query": true },
    "jsonpath": { "selector": "$.orderId" },
    "ignore": { "query": "ts" }
  }]
}

A replay then matches any request to the same path whose body has the same orderId and whose query matches apart from ts.

What Gets Recorded

The recorded response is an is with the upstream's status, headers and body, and _mode: "text" (or "binary" for a body that is not UTF-8), as Mountebank records it. Header names are recorded Title-Cased (Content-Type): Rift's HTTP client sees every upstream name lower-cased, so the upstream's own spelling is not available, and Title-Case is what Mountebank records from a typical HTTP/1 upstream. A name a proxy behavior wrote with an upper-case letter is kept as written. Names are case-insensitive, and Rift serves every name lower-case on the wire either way.

A generator records one predicate per field in matches, as Mountebank does, in the fixed order method, path, query, headers, body, requestFrom, ip, form (Mountebank's order differs; see below), each carrying the generator's caseSensitive and except when they are set (and the body's carrying its jsonpath or xpath). The values are the request's, unmodified. A request GET /users/123?page=2 through

{
  "predicateGenerators": [{
    "matches": { "method": true, "path": true, "query": true, "headers": { "X-Tenant": true } },
    "except": "\\d+"
  }]
}

records

[
  { "deepEquals": { "method": "GET" }, "except": "\\d+" },
  { "deepEquals": { "path": "/users/123" }, "except": "\\d+" },
  { "deepEquals": { "query": { "page": "2" } }, "except": "\\d+" },
  { "equals": { "headers": { "X-Tenant": "acme" } }, "except": "\\d+" }
]
Matcher Recorded predicate
field: true {"deepEquals": {field: value}} — the whole field, exactly. A query with an extra parameter is a different request. A query or form key the request repeated is recorded as the array of its values ({"key": ["first", "second"]}), as Mountebank records it, so the stub replays to the same values in any order
query / headers: {"name": true, …} {"equals": {field: {name: value, …}}} over the listed names; a listed name the request lacks is left out, and a listed query key it repeated is the array of its values. Any value lists the name, as in Mountebank: {"query": {"q": "mountebank"}} records the request's q
"predicateOperator": "exists" {"exists": {field: true}}, or {"exists": {field: {name: true, …}}} for query and headers
any other predicateOperator {operator: {field: value}} for each field

A field the request lacks is recorded empty: no query is {}, no body is "".

Deviations from Mountebank:

  • Order. Mountebank emits the predicates in the key order of matches; Rift's order is fixed, so two recordings of one request always build equal lists.
  • Selector scope. Mountebank copies jsonpath/xpath onto every generated predicate, where it selects from the path or method and records "", so its recording never matches again and it forwards every request; Rift scopes the selector to the body predicate only.
  • Selected value. Mountebank records the selected JSON value (1); Rift records its string form ("1"), which Mountebank also matches.
  • Listed header names. Mountebank finds a listed header name only in the case the client sent it, and otherwise records {}, which matches any request; Rift finds it in any case.
  • Under an explicit predicateOperator other than exists, Mountebank stores the whole request under the operator, once per field in matches. Rift stores that field's value only.
  • A whole query or headers field the generator's ignore filters is recorded with equals over the kept names, so the ignored name may take any value. Mountebank records deepEquals without the ignored name and does not persist ignore, so its recording never matches a request that carries the name.
  • A listed query or form key that the request repeats is recorded as the array of its values. Mountebank records {}, which matches any value of that key.

Recordings made by earlier releases hold one equals predicate per generator, with except already applied and caseSensitive written; they still load and match as they did.

Selective Matching

Match only specific aspects:

{
  "predicateGenerators": [{
    "matches": {
      "method": true,
      "path": true
    }
  }]
}

This generates stubs that match method and path, ignoring query and body.

Case Sensitivity

{
  "predicateGenerators": [{
    "matches": { "path": true },
    "caseSensitive": true
  }]
}

A generated predicate carries caseSensitive only when the generator sets it. When it is omitted, the predicate default applies and the recording ignores case, as in Mountebank. Set "caseSensitive": true to record a predicate that compares case.

Generation Failures

A predicate generator can also be an inject function that builds predicates in JavaScript. If that inject fails when a request reaches it — it throws, returns something other than an array, or exceeds the script timeout — the request is forwarded, its behaviors run, nothing is recorded, and the client gets Mountebank's 500 with {"errors":[{"code":"invalid injection","message":"invalid predicateGenerator injection", "source":"(<fn>)(config);","data":"<the error's message>"}]} instead of the upstream's response (#1708). data is left out for a thrown value without a message (throw 'text'), and names the timeout for a script past its deadline. This holds in every proxy mode, proxyTransparent included, whose generators run on every request and whose predicates are discarded. When the script pool cannot run the generator, or its task panics, the answer is a Rift 500 internal error instead. Each of these responses carries an x-rift-generator-error header naming the failure: script-error, invalid-output, timeout, pool-failure or task-panic.

A generated predicate the engine would not admit is different: the upstream's response is served, nothing is recorded, and it carries x-rift-generator-error: invalid-predicate. Recording a stub with the remaining predicates would match every later request.

invalid-predicate covers any generated predicate the engine would not admit, from any generator: an inject that returns a misspelled operator ({"equal": …}), or a "predicateOperator": "matches" over a value that is not a regular expression (a path such as /a(b). Rift used to drop such a predicate and record the rest; when it dropped all of them, the recording matched every later request. It also covers an unreadable generator entry in a config that reached the engine without the create-time check (a replayed rift-cluster config, for one).

A generator that legitimately returns an empty array ([]) is not a failure — it records a match-all stub as before. Only genuine generation failures skip recording.

A generator xpath selector that fails to evaluate on an XML body (a prefix its ns does not bind, an unknown function) is not in that list. The request is forwarded, nothing is recorded, and the client gets 500 "malformed xpath predicate selector" instead of the upstream's response, as in Mountebank, in every proxy mode, proxyTransparent included. A body that is not XML never runs the selector and records "", and a "predicateOperator": "exists" generator never evaluates it. Rift used to record the body as "" and answer 200.

The create-time dry run

A generator inject is also run once when its stub is created, as in Mountebank: against an empty GET / (no query, headers or form, body "", requestFrom "", no ip) with config.request.isDryRun set to true. An inject that throws, returns something other than an array, or runs past the script timeout refuses the config, whatever the proxy's mode: a 400 from POST/PUT /imposters and every stub route, and a startup or reload error for --configfile, --imposters and POST /admin/reload. The answer is Mountebank's:

{ "errors": [{ "code": "invalid injection", "message": "invalid predicateGenerator injection",
               "source": "(function (config) { … })(config);", "data": "boom" }] }

A script that cannot run on that request (one that parses config.request.body, say) returns early on the flag:

function (config) {
  if (config.request.isDryRun) { return []; }
  return [{ equals: { body: JSON.parse(config.request.body) } }];
}

The refusal's data is the script's error message, so it can differ from Mountebank's where the two JavaScript engines word an error differently (JSON.parse of an empty body), and a non-array return reports Rift's own message. One edge is stricter: an inject that returns a string is refused, where Mountebank admits it and records the string's characters as predicates. Only generator injects are dry-run; response and predicate injects and decorate are not, as in Mountebank. Imposters loaded from --datadir were admitted when they were written and are not dry-run again.


Recording Workflow

Step 1: Create Recording Proxy

curl -X POST http://localhost:2525/imposters \
  -H "Content-Type: application/json" \
  -d '{
    "port": 4545,
    "protocol": "http",
    "stubs": [{
      "responses": [{
        "proxy": {
          "to": "https://api.example.com",
          "mode": "proxyOnce",
          "predicateGenerators": [{
            "matches": { "method": true, "path": true, "query": true }
          }]
        }
      }]
    }]
  }'

Step 2: Run Your Tests

# Run application tests against the proxy
npm test
# or
pytest

Step 3: Export Recorded Stubs

curl http://localhost:2525/imposters/4545?replayable=true > recorded.json

Step 4: Use Recorded Mocks

# Delete proxy imposter
curl -X DELETE http://localhost:2525/imposters/4545

# Load recorded mocks
curl -X POST http://localhost:2525/imposters \
  -H "Content-Type: application/json" \
  -d @recorded.json

Flat Response Form

Rift accepts a stub response in flat form — statusCode, headers, and body at the top level of the response object, with no is wrapper — and serves it identically to the wrapped form. This matters when replaying recorded or externally-generated mocks that emit flat responses:

// Flat form (no "is" wrapper) — accepted and served as a 200 with the body
{ "statusCode": 200, "body": "recorded" }

// Equivalent to the canonical wrapped form
{ "is": { "statusCode": 200, "body": "recorded" } }

statusCode defaults to 200 when omitted. If both a top-level field and an explicit is are present, is takes precedence.


Modifying Proxied Responses

Behaviors on the proxy response

A _behaviors (or behaviors) block on a proxy response runs on the upstream's response before anything is recorded, as in Mountebank. The client, the proxy recording and the stub predicateGenerators generates all get the transformed response:

{
  "proxy": { "to": "https://api.example.com", "mode": "proxyOnce" },
  "_behaviors": {
    "decorate": "function(request, response) { response.headers['X-Proxied-By'] = 'Rift'; }"
  }
}
  • The generated stub holds the transformed body, not the behaviors, so a replay does not run them again. It carries only what addWaitBehavior and addDecorateBehavior add.
  • A proxyOnce replay served from the recording is likewise not transformed a second time.
  • A wait delays the live response, after the upstream's latency is measured, so it is not counted in what addWaitBehavior records.
  • If a behavior fails, nothing is recorded and the next matching request goes upstream again. The failed response is served as usual: lenient by default (with the x-rift-<behavior>-error header), or a 500 under strictBehaviors (a 504 with x-rift-script-timeout when a decorate runs out of time). Recording it would replay a response the configuration did not ask for.
  • A body that is not UTF-8 reaches the behaviors base64-encoded, as a _mode: "binary" body does, and is decoded before it is served.
  • A request body that is not UTF-8 reaches the upstream as the bytes the client sent. Only the request journal, the predicates and the behaviors see its base64 form.
  • The upstream's content-length is dropped when behaviors run, since they can change the body's length; the served length is computed from the body.

addDecorateBehavior

Add a decorate behavior to the stub the proxy generates. It runs when that stub replays, not on the live proxied response, as in Mountebank:

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

addWaitBehavior

Record the upstream's observed latency on the generated stub, so replay reproduces it as a wait behavior. It is a boolean (default false), as in Mountebank, and does not delay the proxied response itself:

{
  "proxy": {
    "to": "https://api.example.com",
    "addWaitBehavior": true
  }
}

Like predicateGenerators, enabling it causes a stub to be generated from the proxied response. The live proxied response also carries an x-rift-proxy-latency: <ms> header when it is set.


HTTPS Proxy

Proxy to HTTPS Backend

{
  "proxy": {
    "to": "https://secure-api.example.com"
  }
}

The origin's certificate is verified against the operating system trust store, and the connection offers http/1.1 only. A TLS failure on this hop (an untrusted issuer, a name mismatch) answers the client 502 with an x-rift-proxy-error: true header and a JSON error envelope (type upstream failure) whose message starts with Proxy error: … and names the upstream. The TLS cause itself, e.g. invalid peer certificate: UnknownIssuer, goes to the server log only.

Trusting a Private CA

Rift verifies outbound TLS against the operating system trust store. An origin issued by an internal CA — a corporate API gateway — needs that CA supplied:

rift --upstream-ca-file /etc/rift/corp-ca.pem
# or
RIFT_UPSTREAM_CA_FILE=/etc/rift/corp-ca.pem rift

The anchor is appended to the OS store, so public origins keep working. The file may hold several certificates, and a missing or unusable one stops startup. This applies to every outbound call Rift makes: proxy stub upstreams, --configfile https://…, and (on the standalone binary) the intercept listener's WebSocket passthrough. Embedders pass upstreamCaFile / upstreamCaPem to rift_serve_admin instead. For a worked example, see TLS/HTTPS → Trusting a Private CA.

SSL_CERT_FILE / SSL_CERT_DIR are also honoured, but they replace the trust store rather than adding to it — pointing SSL_CERT_FILE at a lone private CA silently drops every public root. Use --upstream-ca-file unless you are supplying a complete bundle.

Skipping Verification (development only)

rift --upstream-tls-skip-verify      # or RIFT_UPSTREAM_TLS_SKIP_VERIFY=true

Accepts any certificate and logs a warning. Prefer --upstream-ca-file: a recording proxy with verification disabled will faithfully record MITM'd traffic.

Client Certificate for an mTLS Upstream

A proxy response can present a client certificate to an upstream that requires one (mutual TLS), with the same three keys Mountebank's proxy takes (the PEMs are elided):

{
  "proxy": {
    "to": "https://api.internal:8443",
    "cert": "-----BEGIN CERTIFICATE-----\n...",
    "key": "-----BEGIN ENCRYPTED PRIVATE KEY-----\n...",
    "passphrase": "the key's passphrase"
  }
}
  • cert is the PEM client certificate, leaf first, optionally followed by its intermediates. An X.509 v1 certificate is accepted.
  • key is its PEM private key: unencrypted PKCS#8 (PRIVATE KEY), PKCS#1 (RSA PRIVATE KEY) or SEC1 (EC PRIVATE KEY), or encrypted PKCS#8 (ENCRYPTED PRIVATE KEY, what OpenSSL 3 writes), decrypted with passphrase. OpenSSL's legacy encryption (Proc-Type: 4,ENCRYPTED) is refused; convert it with openssl pkcs8 -topk8 -in key.pem -out key-p8.pem. An encrypted key whose own parameters ask for more than 1,000,000 PBKDF2 iterations (counted per output block, so HMAC-SHA1 with AES-256 counts twice) or 64 MiB of scrypt memory is refused before anything is derived; OpenSSL 3 writes 2048 iterations and 16 MiB. One admin request may ask for 2,000,000 iterations' worth in all, across its distinct keys (a key repeated across its stubs counts once).
  • The pair is checked when the imposter is created: half a pair, a PEM with no certificate or key, a wrong passphrase or a key that does not match the certificate is a 400. Mountebank accepts any of these and fails every proxied request instead (see the difference).
  • Presenting a certificate changes nothing about trust: the upstream's certificate is still verified against the OS trust store plus --upstream-ca-file, or not at all under --upstream-tls-skip-verify, exactly as for a proxy without one.
  • ciphers and secureProtocol (OpenSSL cipher-list and method names) are not modelled and are dropped; rustls' default suites and TLS 1.2/1.3 are used.

The private key is stored and printed back. As Mountebank does, GET /imposters, ?replayable=true and the --datadir file show cert, key and passphrase as posted, the same way an https imposter's own key is shown. Protect the admin API (--api-key, or bind it to loopback) and the datadir accordingly. Rift never writes the key or passphrase to its logs.

CONNECT

An imposter answers CONNECT by tunnelling the client back into itself, whatever the target, as Mountebank does. A client configured with an http or https imposter as its proxy therefore reaches that imposter's stubs: the request it sends through the tunnel is matched as an ordinary request, so a proxy stub forwards it to its to origin.

# The CONNECT target is ignored; the inner request is answered by the imposter on 4545.
curl -k --proxy https://localhost:4545 --proxy-insecure https://api.example.com/repos/x
  • The tunnel reaches nothing but the imposter. Rift never connects to the CONNECT target, so an imposter is not an open forward proxy.
  • Over an https imposter, the client's inner TLS handshake gets the imposter's certificate, so the client must not verify it (curl -k).
  • The CONNECT itself is answered with a bare 200. It is not matched, recorded or counted in numberOfRequests; only the requests sent through the tunnel are.
  • Only an HTTP/1 CONNECT opens a tunnel. Mountebank has no HTTP/2, and an HTTP/2 CONNECT is matched against the stubs like any other request. A CONNECT sent inside a tunnel opens a nested tunnel, up to eight deep. A CONNECT beyond that depth is matched against the stubs like any other request.

To intercept traffic to real hosts, use the intercept proxy.


Header Manipulation

Inject Headers

Add headers to proxied requests:

{
  "proxy": {
    "to": "https://api.example.com",
    "injectHeaders": {
      "X-Forwarded-By": "Rift",
      "Authorization": "Bearer token123"
    }
  }
}

injectHeaders holds one value per name. Naming a header twice (X-Trace and x-trace) is rejected — 400 from POST /imposters, a startup error from --configfile — rather than sending two header lines, and rift-lint reports it before deploy (E043/E044). This diverges from Mountebank, which keeps the last entry; see Single-valued header objects.


Path Rewriting

Modify the request path before forwarding to the backend:

{
  "proxy": {
    "to": "https://api.example.com",
    "pathRewrite": {
      "from": "/api/v2",
      "to": "/api/v1"
    }
  }
}

Use Cases

Version Migration:

Route v2 API calls to v1 backend during migration:

{
  "port": 4545,
  "protocol": "http",
  "stubs": [{
    "predicates": [{ "startsWith": { "path": "/api/v2" } }],
    "responses": [{
      "proxy": {
        "to": "https://legacy-api.example.com",
        "mode": "proxyTransparent",
        "pathRewrite": {
          "from": "/api/v2",
          "to": "/api/v1"
        }
      }
    }]
  }]
}

Strip Prefix:

Remove a prefix from paths:

{
  "proxy": {
    "to": "https://backend.internal",
    "mode": "proxyTransparent",
    "pathRewrite": {
      "from": "/gateway/service",
      "to": ""
    }
  }
}

Request to /gateway/service/users → forwards to /users

Add a Prefix:

to cannot carry a path (see to is an origin); an empty from prefixes every request path instead:

{
  "proxy": {
    "to": "https://backend.internal",
    "mode": "proxyTransparent",
    "pathRewrite": {
      "from": "",
      "to": "/base"
    }
  }
}

Request to /foo/bar?a=1 → forwards to /base/foo/bar?a=1


Combining Proxy with Stubs

Mix static stubs with proxy fallback:

{
  "port": 4545,
  "protocol": "http",
  "stubs": [
    {
      "predicates": [{ "equals": { "path": "/mocked" } }],
      "responses": [{
        "is": { "statusCode": 200, "body": "Mocked response" }
      }]
    },
    {
      "responses": [{
        "proxy": {
          "to": "https://api.example.com",
          "mode": "proxyTransparent"
        }
      }]
    }
  ]
}

Requests to /mocked return the static response; all others proxy to the real API.


Example: API Gateway Pattern

Create a proxy that records and allows overriding specific endpoints:

{
  "port": 4545,
  "protocol": "http",
  "stubs": [
    {
      "predicates": [{ "equals": { "path": "/health" } }],
      "responses": [{ "is": { "statusCode": 200, "body": "OK" } }]
    },
    {
      "predicates": [{ "equals": { "path": "/api/feature-flag" } }],
      "responses": [{ "is": { "body": { "enabled": true } } }]
    },
    {
      "responses": [{
        "proxy": {
          "to": "https://api.example.com",
          "mode": "proxyOnce",
          "predicateGenerators": [{
            "matches": { "method": true, "path": true }
          }]
        }
      }]
    }
  ]
}

Best Practices

  1. Start with proxyOnce - Record responses for consistent tests
  2. Use predicate generators wisely - Too specific = too many stubs
  3. Export regularly - Save recorded mocks to version control
  4. Clean up sensitive data - Review recorded responses for secrets
  5. Use proxyTransparent for debugging - See actual API responses