Guides
Network profiles and fault injection
A network profile adds latency and refuses a fraction of requests, per endpoint, inside the simulator’s own HTTP path. Shaping is off by default and costs nothing until you turn it on. It needs the faults feature, which Pro and Enterprise include.
Only Kalshi ships a profile, off by default (Kalshi → Production latency). RYSTIC_NETWORK=on or POST /_rystic/network with {"enabled":true} turns it on. On any other simulator, {"enabled":true} alone answers 400 cannot enable shaping: no network profile installed, so post a profile.
The profile
POST /_rystic/network
{
"enabled": true, // optional; at least one of enabled / profile
"profile": { // optional; replaces the active profile whole
"id": "any-name",
"default": SHAPE, // optional; any endpoint not listed below
"endpoints": {
"POST /v1/charges": SHAPE // "METHOD template", exactly as GET /_rystic/routes lists it
}
}
}
SHAPE = { "in": DELAY, "out": DELAY, "reject": REJECT } // each optional
DELAY = { "base_ms": 120, "jitter_ms": 40, "correlation": 0.3 }
| { "quantiles_ms": { "p50": 90, "p90": 180, "p99": 400 }, "correlation": 0.3 }
REJECT = { "rate": 0.25, "status": 429, "body": { … } } // or "body_text": "…"; "content_type" overrides
- Keys are
METHOD /templateexactly asGET /_rystic/routesreturns them:POST /v1/charges,GET /v1/customers/{customer}. An endpoint without a key usesdefault, or nothing. indelays before the handler runs.outdelays after state has changed, before the client sees the answer.- A delay is
base_ms±jitter_ms, or aquantiles_mstable (p50required,p90andp99optional).correlationin[0,1)makes consecutive delays drift together instead of jumping independently. rejectanswersrate(0 to 1) of requests withstatusand exactly thebody(JSON) orbody_textyou give. The type isapplication/jsonortext/plainunlesscontent_typesays otherwise.- A rejected request never reaches the handler, so no state moves. Delays still apply. It sets no other headers, so there is no
Retry-After. - Deterministic. Delays and rejections draw from their own seeded stream. The same
seed-rngand the same profile refuse the same requests in the same order on every run, and shaping never shifts the ids or values the vendor routes draw. - Lifetime. A posted profile survives
reset, and{"enabled":false}switches it off and keeps it. A scenario that carries a profile, such as Kalshi’srate_limited, replaces it when it loads or at the times its tape names. The nextresetor scenario brings back the profile from before the scenario. A network write made since, a post to/_rystic/networkor a posted scenario file with anetwork, stays instead. - Scope. Network profiles never shape the control plane, the WebSocket feed or the replay routes.
GET /_rystic/network returns {enabled, source, profile}.
Worked example: throttle Stripe charges with 429s
Start Stripe on a free port and keep the port in PORT (pull first, so run prints nothing but the JSON):
rystic pull stripe-twin
PORT=$(rystic run stripe-twin -d --name faults --port 0 --scenario design_studio --json | jq -r .data.port)
Refuse half of POST /v1/charges with a Stripe-shaped rate-limit error, and add 120 ± 40 ms on the way back:
curl -s -X POST "localhost:$PORT/_rystic/network" -H 'content-type: application/json' -d '{"enabled":true,"profile":{"id":"charges-throttled","endpoints":{"POST /v1/charges":{"reject":{"rate":0.5,"status":429,"body":{"error":{"type":"invalid_request_error","code":"rate_limit","message":"Request rate limit exceeded."}}},"out":{"base_ms":120,"jitter_ms":40}}}}}'
curl -s "localhost:$PORT/_rystic/network" | jq -c '{enabled, source}'
# -> {"enabled":true,"source":"control"}
Send ten charges and count the answers by status:
for i in $(seq 10); do curl -s -o /dev/null -w '%{http_code}\n' "localhost:$PORT/v1/charges" -u sk_test_twin: -d amount=2000 -d currency=usd -d source=tok_visa; done | sort | uniq -c | awk '{print $2, $1}'
# -> 200 6
# -> 429 4
A rate of 0.5 is a draw per request, not every other one, and the same six pass on every run. Only the 200s created charges. Point your client at localhost:$PORT and check that it backs off and retries.
Then switch shaping off, since reset keeps a posted profile, and wipe the charges:
curl -s -X POST "localhost:$PORT/_rystic/network" -d '{"enabled":false}'
curl -s -X POST "localhost:$PORT/_rystic/reset"
curl -s "localhost:$PORT/_rystic/network" | jq -c '{enabled, source}'
# -> {"enabled":false,"source":"control"}
rystic stop faults
A 503 brownout is the same shape with an HTML page: "reject": {"rate": 1, "status": 503, "body_text": "<html>…</html>", "content_type": "text/html"}.
In the state UI
The state UI’s Network card edits the installed profile while the simulator runs. Install one to edit, here a quantile table for Stripe’s customer list, then open http://localhost:$PORT/_rystic/ui in a browser:
PORT=$(rystic run stripe-twin -d --name netui --port 0 --json | jq -r .data.port)
curl -s -X POST "localhost:$PORT/_rystic/network" -d '{"profile":{"id":"slow-customers","endpoints":{"GET /v1/customers":{"out":{"quantiles_ms":{"p50":100,"p90":250,"p99":600}}}}}}'
curl -s "localhost:$PORT/_rystic/network" | jq -c '{enabled, source, id: .profile.id}'
# -> {"enabled":false,"source":"control","id":"slow-customers"}
- shaping ON / shaping OFF switches shaping, as
{"enabled": …}does. With no profile installed, the button is disabled. - Each endpoint row, and a
defaultrow when the profile has one, shows itsinandoutdelay in milliseconds. Type a new value and press Enter, and the card posts the whole profile back. - An edit scales that delay’s distribution: typing
200here makesp90500andp991200, and abase_msedit scalesjitter_mswith it. A delay at0is set flat to the new value. - edited · revert appears once the profile differs from the one the card read when the page loaded, and posts that one back.
- An edit takes effect only while shaping is on. The card edits delays, not
reject.
Done with the card: rystic stop netui.
At boot
rystic run <product> --network <value>, or RYSTIC_NETWORK=<value> for a container or the binary:
| Value | Effect |
|---|---|
off | shaping off; a built-in profile stays installed, inactive |
on | turns on the built-in profile (Kalshi); does nothing on a simulator without one |
| a file path | loads the file. A bare profile, one with an endpoints key and neither profile nor enabled, is installed and enabled. Any other file is read as the POST body above: {"profile":{…}} without "enabled":true installs with shaping off |
A profile with a default but no endpoints key refuses the boot with network body must set enabled and/or profile. Add "endpoints":{}, or wrap it as {"enabled":true,"profile":{…}}.
On a plan without the faults feature, any value refuses the boot: Boot configuration.
Other faults
The faults feature also revokes credentials mid-run (/_rystic/revoke), duplicates webhook deliveries (/_rystic/duplicate-delivery) and caps page sizes (/_rystic/page-cap). Control plane → Routes by plan has each one’s body.