Scenario reference (failure injection)
A scenario is a YAML or JSON document applied to a world:
pnpm exec worlds scenario apply <world_id> scenarios/rate-limit-storm.yaml # a file
pnpm exec worlds scenario apply <world_id> rate-limit-storm # or a library name
pnpm exec worlds scenario clear <world_id>
or via the admin API / clients: POST /admin/worlds/:id/scenario with {"name": "..."} or {"scenario_doc": {...}}.
Rules evaluate deterministically: every rule's request counter lives in world state, so the same request script always hits the same failures. Applying (or re-applying) a scenario resets its counters; a world has at most one active scenario.
name: flaky-stripe
description: what this storm does
rules:
- match: { method: POST, path: "/v1/refunds" }
after_requests: 2 # first 2 matched requests succeed
respond: { status: 429, stripe_error: rate_limit }
duration_requests: 3 # next 3 are rate-limited, then normal
- match: { path: "/v1/customers/*" }
latency_ms: 800 # wall-clock delay; the logical clock is untouched
- webhooks: { drop_every_nth: 3, reorder: swap_adjacent }
- auth: { expire_after_requests: 10 } # then 401 api_key_expired
Rule kinds
Request rules — match on method and/or a path glob (* = one path segment, ** = the rest; /v1/customers/* matches /v1/customers/cus_x but not /v1/customers). Activation window over that rule's matched-request count:
after_requests: N— first N matched requests pass through (default 0)duration_requests: M— apply to the next M matched requests, then stop (omitted = forever)every_nth: K— instead of a window: apply to every Kth matched request (intermittent-500suses this)
Effects: respond: {status, stripe_error} injects a Stripe-shaped error before the handler runs (state is never half-mutated — an injected 429/500 models an edge rejection, not a lost response), and/or latency_ms delays the response. stripe_error values: rate_limit, lock_timeout, api_error, invalid_api_key, api_key_expired.
The two 429s differ on the wire the way they do at Stripe: lock_timeout carries Stripe-Should-Retry: true (another request holds the object's lock; Stripe marks it retryable), rate_limit carries no such header. Official SDKs decide retries by that header first, then 409, then 5xx — never a 429 by status — so a stock client retries a lock_timeout with the same idempotency key and surfaces a rate_limit to the agent. Injected 500s are retried by status, and the twin's idempotency replay answers the retry of a keyed POST from the cache, as Stripe does.
Webhook rules — drop_every_nth: N silently drops every Nth delivery attempt (no retries — it's gone); reorder: swap_adjacent dispatches each adjacent pair within a flush batch in swapped order.
Auth rules — expire_after_requests: N: after N more authenticated twin requests, the API key returns 401 api_key_expired. Clearing the scenario revives the key.
Full field tables: admin-api.md (generated from the zod schemas).
Canned scenarios (scenarios/)
| name | behavior |
|---|---|
rate-limit-storm |
3 requests succeed, then 3 get 429 rate_limit, then recovery — the demo agent's downfall |
intermittent-500s |
every 3rd request returns a 500 api_error |
slow-stripe |
800ms latency everywhere, 1.5s on /v1/customers/* |
webhook-chaos |
drop every 3rd webhook delivery + swap adjacent deliveries |
auth-expiry |
key expires after 10 requests → 401 api_key_expired |
tickets-storm |
every 4th ticket update (PUT /api/v2/tickets/*) fails with a Zendesk-shaped 500; Stripe untouched — the shift demo's claimed-vs-did act |
stripe-storm-shift |
shift-scale Stripe weather: a 6-request 429 window after 25 calls, then every 7th write; two rules, two counters |
lock-timeouts |
every 5th Stripe write returns a 429 lock_timeout with Stripe-Should-Retry: true — the storm a stock official SDK retries through silently, so the agent never sees it |