Skip to main content

Stunt

An understudy for your backend.

Stunt is a mitmproxy addon for local frontend and mobile testing. It lets you mock, modify, and delay HTTP/HTTPS traffic using a simple rules.yaml. The goal is a low-skill-floor tool that scales from quick UI checks to advanced, rule-driven network simulation.

  • Fast UI iteration without a backend.
  • Deterministic mocking and targeted JSON mutations.
  • Works for web, iOS, and Android.
  • Hot reload of rules and mock files.
  • Simple YAML for juniors, powerful matching for seniors.

Install

Requires Python 3.12 or newer.

pipx install stunt        # recommended: isolated, still on your PATH
uv tool install stunt     # same idea, if you use uv
pip install stunt         # or into a venv you manage yourself

uvx stunt runs it without installing anything.

Any of these puts a stunt command on your PATH for the rest of this README.

From a clone (for developing Stunt itself)
git clone https://github.com/Untargetable/stunt.git
cd stunt
python -m venv venv
source venv/bin/activate   # see Contributing below for other shells
pip install -e .

Venv activation for other shells and running the test suite are covered in Contributing / Development.

Your First Mock in 60 Seconds

Create a rules.yaml in an empty directory:

mock:
  /api/status: {status: "ok"}

That one line mocks GET /api/status on any host with a 200 application/json response — no backend required. (stunt init scaffolds this file for you, plus a mocks/ folder with an example — see stunt init below.)

Start the proxy:

stunt --runner dump

This listens on port 8080 on all interfaces (0.0.0.0), which is what lets a phone on the same Wi-Fi reach it — and also means anyone on your network can. On an untrusted network, restrict it with --listen-host=127.0.0.1. Before pointing any real client at it, trust its certificate once: with the proxy running, open http://mitm.it through it (e.g. curl -x http://127.0.0.1:8080 http://mitm.it or a browser configured to use the proxy — see Certificate & Client Setup for every platform). Skipping this makes every HTTPS request fail with a certificate error.

Now verify the mock fires, with no backend running at all:

curl -x http://127.0.0.1:8080 http://api.example.com/api/status
# {"status": "ok"}

That's it — that's the loop. Edit rules.yaml, save, and the running proxy hot-reloads it; no restart needed.

Each mock: key is [METHOD ]path. The method is optional; without it every method matches. The path is matched exactly (a query string is ignored, so /api/users?page=2 still matches, but /api/users/1 does not).

Each value is either a bare body — any JSON object, array or scalar, served as 200 application/json — or a respond_with object when you need to set the status, headers, or a mock file:

mock:
  GET /api/users: {id: 1, name: "Ada"}          # bare body
  POST /api/orders:                             # respond_with object
    status: 201
    body: {ok: true}
  /health: {status: "up"}                       # bare body (see below)
  /pdp: {file: "pdp.json"}                      # served from mocks/pdp.json

How the two are told apart: a value is a respond_with object only if it is a non-empty map whose keys are all from status, body, file, headers, json and whose status, if present, is an integer. Everything else is a bare body. So {status: "up"} is a health-check body, and {status: 201, id: 5} is a body too (id is not a respond_with key). If you ever want the strict reading, be explicit: {body: {status: 201, id: 5}}.

When you need matchers, state, delays, cycling responses or JSON mutation, graduate to the full rules: form — mock: desugars into exactly those rules, so nothing behaves differently.

Certificate & Client Setup

Every client needs the mitmproxy CA certificate trusted once, and its proxy settings pointed at wherever Stunt is running (port 8080, all interfaces — use your machine's LAN IP from a phone, 127.0.0.1 from the same machine).

Web: Chrome / Edge

  1. Open system proxy settings.
  2. Set HTTP/HTTPS proxy to the host/port where Stunt is running (default 127.0.0.1:8080).
  3. Visit http://mitm.it in the browser to install the mitmproxy certificate.

Web: Firefox

  1. Settings → Network Settings → Manual proxy.
  2. Same host/port as Stunt.
  3. Install cert from http://mitm.it or import into Firefox certificate store.

iOS

  1. Ensure phone and laptop are on the same Wi-Fi.
  2. On iOS: Wi-Fi → (i) → Configure Proxy → Manual.
  3. Set server to your laptop IP and port to Stunt port (default 8080).
  4. Open Safari to http://mitm.it and install the iOS certificate.
  5. Enable full trust: Settings → General → About → Certificate Trust Settings.

Android

  1. Ensure device and laptop are on the same Wi-Fi.
  2. Android Wi-Fi → Edit network → Proxy → Manual.
  3. Set host to your laptop IP and port to Stunt port.
  4. Open browser to http://mitm.it and install the cert (user CA).

Note: Some apps use certificate pinning. In those cases, either disable pinning in your dev build or use a debug build that trusts user CAs.

Everyday Recipes

More copy-paste recipes live in docs/cookbook.md. A few common ones to get started:

Simulate a slow API

- name: "slow search"
  url_contains: "/api/search"
  delay:
    random: [0.3, 1.5]

Force a 500

- name: "simulate outage"
  url_contains: "/api/heavy"
  error:
    status: 500
    body: { "message": "Internal error" }

Mock an endpoint whose backend is down

respond_with (and the mock: shorthand) is served in the request phase, so it never touches the network — the mock fires even with no upstream running at all:

- name: "profile from file"
  url_contains: "/api/profile"
  respond_with:
    file: "sample_response.json"
    headers:
      Content-Type: "application/json"

Simulate a slow mobile connection

delay adds latency; throttle adds transfer time, so a big payload stops arriving instantly. Together they model a real connection:

global_delay: 0.2      # latency, applies to every request
throttle: slow-3g      # bandwidth, applies to every response body

Simulate the network dropping

kill: true drops the connection in the request phase — the client sees a broken connection, not an HTTP status code:

- name: "offline"
  url_contains: "/api/"
  kill: true

Test your timeout handling

Hang for 10 seconds, then drop — delay runs before the action, so a timeout is just delay + kill:

- name: "hang then drop"
  url_contains: "/api/checkout"
  delay: { fixed: 10 }
  kill: true

Modify one field of a real response

This fetches the real response first, then edits it — the upstream must be reachable:

- name: "patch one field"
  url_contains: "/api/profile"
  modify_response_json:
    set:
      $.betaFlag: true

Tools

stunt init

Scaffolds a working setup in the current directory: rules.yaml (with the schema modeline and a couple of mock: shorthand entries) plus mocks/ with one example mock file. This is the intended way to create mocks/; the addon never creates it speculatively.

stunt init          # creates rules.yaml + mocks/, skips files that already exist
stunt init --force  # overwrites them instead

It prints what it created or skipped, and a short next-step hint for starting the proxy and trusting the mitmproxy CA cert.

stunt lint

Validates a rules file without starting a proxy — good for CI or a pre-commit hook. It reuses the exact same YAML parsing and rule-compiling code path the live addon uses, so lint results and runtime behaviour never drift apart.

stunt lint                              # discovers rules.yaml the same way the proxy does
stunt lint --rules ./rules.yaml --mocks ./mocks

It reports, and exits non-zero on:

  • YAML syntax errors, with line/column.
  • Unknown keys, with near-miss suggestions (e.g. respnd_with → did you mean respond_with?).
  • Rules with no recognised action (a typo'd action key silently disables the rule).
  • Rules with no matchers at all — these match every request, which is almost always a mistake.
  • respond_with/error/cycle/random entries whose file: mock doesn't exist.

Exits 0 with an OK: <path> summary line when the file is clean.

Record real traffic (--record)

Run your app once against the real backend with recording on, stop the proxy, and you have a rules.yaml that replays those responses with the backend switched off.

stunt --runner dump --record ./rules.yaml \
    --record-host '^api\.example\.com$' \
    --record-path '^/api/'
# ... exercise your app, then Ctrl-C
# stunt --record: wrote ./rules.yaml (7 mock entries, 2 rule(s))
# stunt --record: wrote 2 body file(s) to ./mocks
Flag Effect
--record PATH Record this session and write it to PATH on shutdown. Off unless given.
--record-host REGEX Only record hosts matching this regex — same dialect as the host matcher.
--record-path REGEX Only record paths matching this regex — same dialect as path_regex.
--record-force Allow --record to overwrite an existing rules file.
--record-raw Turn secret scrubbing off and record bodies verbatim.

Recording is CLI-only and off by default. There is no record: key in rules.yaml: recording writes a rules file, so a switch living inside one would re-arm itself on hot reload and overwrite the file it was read from.

What you get:

  • First response per METHOD + path wins — a replay reproduces what your app saw first, not the last cache-buster.
  • mock: shorthand for the ordinary case (200, JSON, single host). Anything needing more — non-200 status, a custom Content-Type, several hosts — becomes a full rules: entry with method/path_regex (and host) matchers.
  • Bodies: JSON bodies of 2048 bytes or less are inlined into the rules file; bigger ones, and anything that isn't JSON, are written to mocks/ and referenced with file:. 2 KiB is roughly the largest blob that still reads and diffs like config rather than like data.
  • Headers: only Content-Type is carried over. That's an allowlist, so hop-by-hop headers and Authorization/Cookie/Set-Cookie are skipped by construction.
  • Scrubbing (on by default): recorded response bodies are scanned before anything is written. Values under sensitive JSON keys (access_token, password, apiKey, sessionId, …), JWTs anywhere in a body, and long high-entropy opaque strings are replaced with <redacted>. JSON is walked by key; other bodies get a regex pass. The header of the generated file and the shutdown report both say how many values were replaced. --record-raw disables it when you genuinely need the real payload.
  • No clobbering: an existing target file aborts recording at startup unless you pass --record-force, and the run tells you exactly what was written.
  • Responses Stunt itself served are never recorded — only real upstream traffic.

Review recordings before committing them. Scrubbing is a best-effort safety net against accidental commits, not a DLP tool: it knows common token shapes and key names, and it deliberately leaves ordinary-looking values alone. Emails, order IDs, names and any secret that doesn't look like one still land in rules.yaml and mocks/. The output is plain YAML — read it, trim it.

The result passes stunt lint and validates against docs/stunt_schema.json as-is.

Match-trace log (--trace-matches)

The most common question when mocking traffic: "why didn't my rule fire?" Opt in with --trace-matches on the CLI, or --set stunt_trace=true, or a top-level trace: true in rules.yaml. It costs nothing when off — the check is a single attribute read on the hot path.

stunt --runner dump --trace-matches

Per request, it logs one block naming every rule considered and, for each one that didn't match, which specific condition rejected it:

Stunt trace [request] GET /api/x
  rule 'users-get': path_regex did not match '/api/x'
  rule 'orders-post': method 'GET' not in ['POST']
  no rule matched — falling through to the real backend

A matching rule shows what it did instead (MATCHED -> served mock response, etc.). Trace output respects quiet: true — a quiet addon stays quiet even with tracing on.

Commands in the mitmweb UI

Stunt registers commands with mitmproxy, so its rules stop being invisible while traffic scrolls past. They work in every runner:

  • mitmweb (the default) — hit : to open the command palette, then start typing stunt. Results are printed in the palette.
  • mitmproxy console (--runner proxy) — same thing, : then the command name.
  • HTTP — mitmweb serves GET /commands and POST /commands/<name>, so a script can drive them too.
Command What it does
stunt.rules.list Every loaded rule in priority order, with its hit count and whether it is active, exhausted (a spent once/count), gated (its state.require is not satisfied), or disabled
stunt.rules.toggle <name> Turn one rule off (or back on) for this session only
stunt.state.get Dump the current state dict
stunt.state.set <key> <value> Set one state key. The value is parsed as JSON when it parses (2 is the number 2, true a bool, "x" a string), otherwise kept as a plain string
stunt.record.start <out> [host] [path] Arm recording to a rules file, with the optional host/path regex filters --record-host/--record-path take
stunt.record.stop Stop recording and write the file now, without shutting the proxy down
stunt.reload Force a reload of rules.yaml even though the file has not changed

Sample output:

Stunt: 2 rule(s), priority order:
  * flaky-checkout  prio=10   hits=1    exhausted (once)
  - step-two        prio=0    hits=0    DISABLED (session)

stunt.rules.toggle is in-memory only. It never edits rules.yaml, and because rules.yaml is the hot-reloading source of truth, the next reload wipes every toggle — saving the file, stunt.reload, or touching a mock all restore the rule. Use it to answer "is this rule the one breaking my page?" in a single click; use the file for anything you want to keep.

Reload also resets state, hit counts and cycle positions, so a stunt.state.set is equally session-scoped.

Full Reference

The sections below are reference material, not a tutorial — see Your First Mock in 60 Seconds and Everyday Recipes for the narrative path.

Choosing the Runner

stunt defaults to mitmweb (web UI). You can switch runners:

stunt --runner web    # mitmweb (default)
stunt --runner proxy  # mitmproxy (terminal UI)
stunt --runner dump   # mitmdump (headless)

--mode is a deprecated alias for --runner (still works, prints a warning) — it was renamed because mitmproxy has its own --mode flag (for reverse:URL, transparent, socks5, upstream:URL; see Reverse-Proxy Mode), and the old name shadowed it. Any --mode value that isn't web/proxy/dump is assumed to be mitmproxy's own and forwarded straight through.

You can also pass any mitmproxy arguments after these options:

stunt --runner web --listen-host 0.0.0.0 --listen-port 8080

Use --rules / --mocks to point at a specific config instead of relying on discovery (see Locating rules.yaml and mocks/):

stunt --rules ./rules.yaml --mocks ./mocks

Reverse-Proxy Mode

Regular mode (the default) is a forward proxy: the client has to be configured to use 127.0.0.1:8080 as its proxy, and it has to trust Stunt's CA certificate for HTTPS. That's fine for a browser or curl, but it's friction for a mobile app or a frontend that can't easily be pointed at a system proxy.

Reverse mode flips this: Stunt listens like an ordinary HTTP server and forwards every request to one fixed backend, mocking whatever your rules match along the way. The client just changes its base URL to http://localhost:PORT — no proxy setting, no CA install. This is the lowest-friction option when you can change a base URL (mobile apps, frontends hitting an API base URL, CI jobs) and want mocks without touching client config.

stunt --runner dump --mode reverse:https://api.example.com

Now http://localhost:8080/anything is forwarded to https://api.example.com/anything unless a rule intercepts it first — including the mock: shorthand, unchanged:

mock:
  GET /api/status: {status: "ok"}
curl http://localhost:8080/api/status   # -> {"status": "ok"}, no -x, no CA needed

One caveat, specific to reverse mode: the upstream must be reachable, even though its responses are discarded. mitmproxy dials the upstream while setting up a reverse-mode flow, so if nothing is listening there the connection fails before any Stunt rule runs. This affects every rule type equally — respond_with, mock: and error alike. Point reverse: at any listening port (it can return garbage); the mock still wins.

In normal forward-proxy mode there is no such limitation: mocks are served in the request phase and short-circuit before the network, so a rule serves fine with no backend at all.

--mode here is mitmproxy's own flag, not Stunt's runner selector — see Choosing the Runner for why they're separate flags.

Config Files

  • rules.yaml controls global settings and rules.
  • mocks/ stores response bodies (JSON, text, fixtures).
  • JSON schema is in docs/stunt_schema.json for editor validation.

The repo includes a full reference rules.yaml with a disabled kitchen-sink rule and practical examples. See rules.yaml.

Editor autocomplete and typo checking

Files written by stunt init and stunt --record start with a modeline pointing editors at the schema:

# yaml-language-server: $schema=https://raw.githubusercontent.com/Untargetable/stunt/main/docs/stunt_schema.json

With the YAML extension in VS Code, or the built-in JSON Schema support in JetBrains IDEs, this gives you autocomplete for every rule key and flags anything the schema doesn't recognise — including a typo like path_regexp or respond: — directly in the editor, before you ever start the proxy.

Working inside a clone of this repo, you can point at the local copy instead, which is what this repo's own rules.yaml does:

# yaml-language-server: $schema=./docs/stunt_schema.json

Locating rules.yaml and mocks/

By default stunt looks for rules.yaml (and creates/uses mocks/ next to it) by checking, in order: $STUNT_HOME, the parent of $VIRTUAL_ENV, then walking up from the current directory and from the addon's own location for a folder that looks like a project root. If nothing is found, it warns on startup and loads zero rules rather than failing silently.

To be explicit instead of relying on discovery:

stunt --rules /path/to/rules.yaml --mocks /path/to/mocks
# or
export STUNT_HOME=/path/to/project   # rules.yaml and mocks/ live here
stunt

--rules / --mocks always win over $STUNT_HOME and the discovery walk-up.

Full Option Reference

Top-level

  • quiet (bool): suppress rule logs (errors still logged).
  • trace (bool): opt-in match-trace log — see Match-trace log. Same effect as --trace-matches.
  • global_delay (number | { random: [min, max] | number }): delay before processing each request.
  • throttle (preset string | number | { kbps: number }): bandwidth throttling applied to every response body — see Network Conditions.
  • state (object): initial state values for stateful rules.
  • mock (object): shorthand mocks keyed by [METHOD ]path — see Your First Mock in 60 Seconds. Desugars into normal rules at priority -1, so an explicit rules: entry (default priority 0) always wins when both could match. mock: and rules: can be used together.
  • defaults (object): rule keys shallow-merged into every rule, including the ones mock: generates. A rule's own key always wins. Useful for the keys you would otherwise repeat on every rule: host, method, header_contains, query_contains, passthrough, priority, delay, quiet, max_body_size. Avoid defaulting per-rule identity or actions (name, respond_with, once, count, state).
  • rules (array): list of rule objects — the full form, with matchers, state, delays and mutations.
defaults:
  host: "api\\.example\\.com"     # every rule below is scoped to this host
mock:
  /api/users: {id: 1}
rules:
  - name: "explicit wins"
    path_contains: "/api/users"
    respond_with: {status: 200, body: {from: "rules"}}

Rule fields

Core:

  • name (string): rule name for logs.
  • enabled (bool): enable/disable the rule.
  • priority (int): higher runs first.
  • probability (0..1): random execution chance.
  • count (int): max number of times to apply.
  • once (bool): apply at most once.
  • quiet (bool): override global quiet per rule.
  • state:
    • require (object): key/value matchers; supports { "$exists": bool }.
    • set (object): sets state keys on rule hit.
    • delete ([string]): removes state keys on rule hit.
    • Shorthand: a rule-level state: given as a bare object with none of require/set/delete as keys is treated as state.require (a plain map of key/value matchers). This is the opposite of top-level state:, which sets initial values. Example: state: { stage: "start" } on a rule means "require stage == start to match" — it does not set stage.
  • delay:
    • fixed (number): delay in seconds.
    • random ([min, max] | number): delay range in seconds, or a single number for a fixed-width "random" delay of that value.
    • If both fixed and random are set, fixed silently wins and random is ignored.
    • Values are clamped to the 0-10s range. Clamping logs a warning naming the rule and both the requested and the applied value.
  • throttle (preset string | number | { kbps: number }): bandwidth for this rule's flow, overriding the top-level throttle — see Network Conditions.
  • cycle ([respond_with]): cycle through multiple responses on each match.
  • random ([respond_with]): choose a response at random on each match.
  • passthrough (bool): fetch the real response first, then serve respond_with/cycle/ random in the response phase instead of short-circuiting the request.

Matchers:

  • method (string | [string]): HTTP method(s).
  • host (regex string): matches host.
  • url_contains (string)
  • url_regex (regex string)
  • path_contains (string)
  • path_regex (regex string)
  • status_code (int | [int])
  • header_contains ({ header: substring })
  • query_contains ({ key: substring })
  • json_body ({ jsonpath: value | {"$exists": bool} })
  • json_contains (object | array): request body must contain this shape — recursive subset. Objects match when every key/value in the pattern is present and matches; arrays match when every pattern element is contained somewhere in the target array; extra keys and extra elements in the body are ignored. A leaf of {"$regex": "..."} matches by re.search instead of by equality. No JSONPath needed: paste an example body and trim it down. Combines with json_body (both must pass).
  • response_json ({ jsonpath: value | {"$exists": bool} })
  • response_json_contains (object | array): same containment semantics, against the response body. Like response_json, it forces the rule onto the response phase.

Actions:

  • respond_with:
    • status (int)
    • body (any)
    • file (string): load from mocks/
    • json (bool)
    • headers (object)
  • error:
    • status (int)
    • body (any)
    • file (string)
    • headers (object)
  • modify_request_headers:
    • set (object)
    • remove ([string])
  • modify_request_query:
    • set (object)
    • remove ([string])
  • modify_request_json:
    • set (object)
    • append (object)
    • delete ([jsonpath])
    • merge ({ jsonpath: spec }): recursive deep merge. Objects merge key by key, anything else overwrites. A spec that is a plain object is merged into every match. A spec containing $where treats the match as an array and acts on every element that contains the $where shape (same predicate as json_contains), applying either $merge (deep merge into the element) or $replace (swap the whole element out). Not implemented: insert, move, delete, negated, forall — merging or replacing every predicate match covers the cases those exist for; delete already has its own key with JSONPath filters.
  • modify_response_headers:
    • set (object)
    • remove ([string])
  • modify_response_json:
    • set (object)
    • append (object)
    • delete ([jsonpath])
    • merge ({ jsonpath: spec }): recursive deep merge. Objects merge key by key, anything else overwrites. A spec that is a plain object is merged into every match. A spec containing $where treats the match as an array and acts on every element that contains the $where shape (same predicate as json_contains), applying either $merge (deep merge into the element) or $replace (swap the whole element out). Not implemented: insert, move, delete, negated, forall — merging or replacing every predicate match covers the cases those exist for; delete already has its own key with JSONPath filters.
  • kill (bool): drop the connection instead of answering. Request phase only — the upstream is never contacted. See Network Conditions.
  • max_body_size (int): max JSON body size in bytes for parsing.

Behavior notes:

  • The first matching rule applies and stops processing further rules.
  • respond_with / error take precedence when matched.
  • cycle / random override respond_with.
  • respond_with / cycle / random are served in the request phase, so they work with no upstream at all and never reach the real backend. A rule matching on status_code or response_json needs the real response first and is therefore served in the response phase; passthrough: true asks for that explicitly.
  • A rule with no recognised action is reported at load time and ignored. So is a request-phase action (error, kill, modify_request_*) on a rule that matches with status_code / response_json* — those matchers need a response, which is a phase where those actions never run.
  • JSON modifications only apply to valid JSON bodies within max_body_size. A body that is not JSON makes the modification a no-op; the rule has still matched, so processing stops there rather than falling through to a lower-priority rule.
  • A delay-only or state-only rule is still a match: it applies and stops the scan. Put a "slow this endpoint down" rule after the rule that mocks the same path, or give the mock the higher priority, or the mock never runs.
  • delay is applied before once / count budgets are claimed, so under concurrency every racing flow waits and only the winners are served by that rule.
  • State resets on rules.yaml reload and can be initialized via top-level state.
  • kill runs before error and respond_with; a killed flow has no response at all.
  • throttle is charged once per flow, on whichever phase first has a response body.

Network Conditions (throttle / kill)

delay models latency — a fixed wait before anything happens. It does not model bandwidth: a 5 MB payload still arrives in one piece. throttle fills that gap, and kill covers the failures no HTTP status code can express.

throttle — bandwidth

Set it at the top level (every flow) or per rule (that flow only; a rule's own value wins):

throttle: 3g                     # preset
# throttle: { kbps: 256 }        # explicit rate in kbit/s
# throttle: 256                  # same thing, shorthand

rules:
  - name: "slow image endpoint"
    path_contains: "/images/"
    throttle: gprs

Presets, in kbit/s — the numbers line up with what Chrome DevTools and Charles use:

Preset kbit/s ≈ bytes/s 1 MB takes
gprs 50 6,250 168 s (capped at 30 s)
2g 240 30,000 35 s (capped at 30 s)
slow-3g 400 50,000 21 s
3g 1600 200,000 5.2 s
dsl 2000 250,000 4.2 s
4g 4000 500,000 2.1 s
wifi 30000 3,750,000 0.3 s

How it is approximated. Stunt computes len(body) / rate and sleeps once, then hands the whole body over. It is not packet-level pacing: the client sees nothing, then everything. That is enough to exercise spinners, timeouts and progress states, but it will not reproduce chunked-streaming or first-byte behaviour.

Other guarantees:

  • Charged once per flow. A mock served in the request phase is throttled there; a real response is throttled in the response phase. Never both.
  • Composes with delay: total wait is latency + transfer time.
  • Capped at 30 s per transfer, so a typo (kbps: 0.01) cannot hang the proxy. When the cap bites, a warning names the body size and the uncapped duration.
  • An unknown preset or a non-positive rate logs a warning and is ignored.

kill — connection-level failure

error: returns a synthetic HTTP response. Sometimes the thing you need to test is the absence of one: a dropped connection, an offline network, a request that hangs and then dies. kill: true calls mitmproxy's flow.kill() in the request phase, so the upstream is never contacted and the client gets a transport-level failure.

rules:
  # the network is gone
  - name: "offline"
    host: "api\\.example\\.com"
    kill: true

  # a request that hangs, then dies — your timeout handling under test
  - name: "gateway timeout"
    path_contains: "/api/checkout"
    delay: { fixed: 10 }
    kill: true

  # flaky: one request in five drops
  - name: "flaky link"
    path_contains: "/api/"
    probability: 0.2
    kill: true

kill is deliberately a plain boolean rather than a variant of error:error: always produces a response, and the whole point here is that there is not one. The "timeout" case needs no key of its own: delay already runs before the action, so delay + kill is hang-then-drop.

Stateful Flows (State + Cycle/Random)

Require and Set State

state:
  stage: "start"

rules:
  - name: "step-1"
    url_contains: "/checkout"
    state:
      require:
        stage: "start"
      set:
        stage: "confirmed"
    respond_with:
      status: 200
      body: { "ok": true, "step": 1 }

Cycle Responses

rules:
  - name: "toggle-flag"
    url_contains: "/feature"
    cycle:
      - status: 200
        body: { "enabled": false }
      - status: 200
        body: { "enabled": true }

Random Responses

rules:
  - name: "flaky"
    url_contains: "/health"
    random:
      - status: 200
        body: { "ok": true }
      - status: 500
        body: { "ok": false }

Atomic Examples (Every Field)

Each snippet below is a standalone rule. You can paste into rules.yaml under rules:.

quiet:

quiet: true
rules: []

global_delay fixed:

global_delay: 0.3
rules: []

global_delay random:

global_delay:
  random: [0.1, 0.6]
rules: []

name:

- name: "basic"
  url_contains: "/api"

enabled:

- name: "disabled"
  enabled: false
  url_contains: "/api"

priority:

- name: "high-priority"
  priority: 100
  url_contains: "/api"

probability:

- name: "flaky"
  probability: 0.3
  url_contains: "/api"

count:

- name: "only-twice"
  count: 2
  url_contains: "/api"

once:

- name: "only-once"
  once: true
  url_contains: "/api"

quiet (per-rule):

- name: "quiet-rule"
  quiet: true
  url_contains: "/api"

delay fixed:

- name: "delay-fixed"
  delay:
    fixed: 0.5
  url_contains: "/api"

delay random:

- name: "delay-random"
  delay:
    random: [0.2, 1.0]
  url_contains: "/api"

method:

- name: "post-only"
  method: "POST"
  url_contains: "/api"

host:

- name: "host-regex"
  host: "^api\\.example\\.com$"

url_contains:

- name: "url-contains"
  url_contains: "/v1/orders"

url_regex:

- name: "url-regex"
  url_regex: "/v1/orders/\\d+"

path_contains:

- name: "path-contains"
  path_contains: "/orders"

path_regex:

- name: "path-regex"
  path_regex: "/orders/\\d+"

status_code:

- name: "status-404"
  status_code: [404]

header_contains:

- name: "ua-match"
  header_contains:
    user-agent: "Mozilla"

query_contains:

- name: "query-match"
  query_contains:
    locale: "en"

json_body:

- name: "json-body"
  json_body:
    $.user.id: 123

json_body exists:

- name: "json-body-exists"
  json_body:
    $.user.id: { "$exists": true }

response_json:

- name: "response-json"
  response_json:
    $.data.id: 123

json_contains (no JSONPath required — paste a trimmed example body):

- name: "contains"
  json_contains:
    user: { role: "admin" }
    items:
      - { sku: "B" }

json_contains with a regex leaf:

- name: "contains-regex"
  json_contains:
    email: { "$regex": "@example\\.com$" }

response_json_contains:

- name: "resp-contains"
  response_json_contains:
    errors:
      - { code: "E1" }

response_json exists:

- name: "response-json-exists"
  response_json:
    $.data.id: { "$exists": true }

respond_with inline JSON:

- name: "respond-inline"
  respond_with:
    status: 200
    json: true
    body: { "ok": true }

respond_with file:

- name: "respond-file"
  respond_with:
    status: 200
    file: "sample_response.json"
    headers:
      Content-Type: "application/json"

respond_with text:

- name: "respond-text"
  respond_with:
    status: 200
    json: false
    body: "plain"
    headers:
      Content-Type: "text/plain"

error:

- name: "inject-error"
  error:
    status: 500
    body: { "error": "boom" }

error file:

- name: "error-file"
  error:
    status: 503
    file: "sample_response.json"

modify_request_headers:

- name: "req-headers"
  modify_request_headers:
    set:
      X-Test: "1"
    remove: ["X-Remove"]

modify_request_query:

- name: "req-query"
  modify_request_query:
    set:
      test: "1"
    remove: ["utm_source"]

modify_request_json set:

- name: "req-json-set"
  modify_request_json:
    set:
      $.flag: true

modify_request_json append:

- name: "req-json-append"
  modify_request_json:
    append:
      $.items:
        id: "NEW"

modify_request_json delete:

- name: "req-json-delete"
  modify_request_json:
    delete:
      - $.items[?(@.id=="REMOVE")]

modify_response_headers:

- name: "resp-headers"
  modify_response_headers:
    set:
      X-Proxy: "1"
    remove: ["X-Powered-By"]

modify_response_json set:

- name: "resp-json-set"
  modify_response_json:
    set:
      $.flag: true

modify_response_json append:

- name: "resp-json-append"
  modify_response_json:
    append:
      $.items:
        id: "EXTRA"

modify_response_json delete:

- name: "resp-json-delete"
  modify_response_json:
    delete:
      - $.items[?(@.id=="REMOVE")]

modify_response_json merge (deep merge):

- name: "resp-json-merge"
  modify_response_json:
    merge:
      $.user:
        role: "admin"
        prefs: { theme: "dark" }   # sibling keys under prefs survive

modify_response_json merge into array elements by predicate:

- name: "resp-json-merge-where"
  modify_response_json:
    merge:
      $.items:
        $where: { sku: "A" }       # every element containing this shape
        $merge: { price: 0 }       # ...gets deep-merged; use $replace to swap it whole

max_body_size:

- name: "limit"
  max_body_size: 1024
  modify_response_json:
    set:
      $.ok: true

Contributing / Development

The Install section covers the venv + editable install every contributor also needs. A couple of extra details for hacking on Stunt itself:

Activating the venv on shells other than bash/zsh:

# fish
source venv/bin/activate.fish

# C shell (csh, tcsh)
source venv/bin/activate.csh

# Windows PowerShell
.\venv\Scripts\Activate.ps1

Skip re-activating the venv every time

bash/zsh (~/.bashrc or ~/.zshrc):

alias run_stunt='source /path/to/stunt/venv/bin/activate && stunt'

fish (~/.config/fish/config.fish):

function run_stunt
    source /path/to/stunt/venv/bin/activate.fish
    stunt
end

PowerShell (add to $PROFILE, then run notepad $PROFILE to edit it):

function run_stunt {
  $venvPath = "C:\path\to\stunt\venv\Scripts\Activate.ps1"

  if (Test-Path $venvPath) {
    & $venvPath
    stunt
  } else {
    Write-Error "Virtual environment activation script not found at $venvPath"
  }
}

Then just run:

run_stunt

Running the tests

pip install -e .[test]
pytest

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

stunt-1.0.1.tar.gz (76.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

stunt-1.0.1-py3-none-any.whl (42.7 kB view details)

Uploaded Python 3

File details

Details for the file stunt-1.0.1.tar.gz.

File metadata

  • Download URL: stunt-1.0.1.tar.gz
  • Upload date:
  • Size: 76.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.7

File hashes

Hashes for stunt-1.0.1.tar.gz
Algorithm Hash digest
SHA256 877c1273806e12e9e422fc768d80053e988113c3aa1f2f4ca7425556f169e28a
MD5 bcdbc02103c275fc1083d41f27bd7f85
BLAKE2b-256 adde499d464125c3c94000365e9761b7f9f9ee105d3e8c60bb2b162efca285d2

See more details on using hashes here.

File details

Details for the file stunt-1.0.1-py3-none-any.whl.

File metadata

  • Download URL: stunt-1.0.1-py3-none-any.whl
  • Upload date:
  • Size: 42.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.7

File hashes

Hashes for stunt-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 93d1c258a7c994e5fa96f90dc04ea5e7cfe5a8413c450861bff0350ec317c7e9
MD5 282b962dfa0fc378577090de700bd044
BLAKE2b-256 82ab0934641b28ee941582ee5113f75c08b4f91310261d4b6e9f0caace5420fa

See more details on using hashes here.

Release history Release notifications | RSS feed

1.0.2

2 files

This release

1.0.1 This release

2 files

1.0.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page