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 rulesmatch 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-500s uses 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 rulesdrop_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 rulesexpire_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