Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/patterns/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A pattern is a reusable solution to a recurring problem when building API simula

Most projects start with [Explore a New API](./explore-new-api.md) or [Executable Spec](./executable-spec.md) to get a running server from an OpenAPI spec with no code. From there, [Mock APIs with Dummy Data](./mock-with-dummy-data.md) and [AI-Assisted Implementation](./ai-assisted-implementation.md) are the natural next steps for adding realistic responses — the former by hand, the latter with an AI agent doing the heavy lifting.

As the mock grows, [Scenario Scripts](./scenario-scripts.md) let you automate repetitive REPL interactions — seeding data on startup, building reusable request sequences — while [Federated Context Files](./federated-context.md) and [Test the Context, Not the Handlers](./test-context-not-handlers.md) keep the stateful logic organized and reliable. [Live Server Inspection with the REPL](./repl-inspection.md) is Counterfact's most distinctive feature, letting you seed data, send requests, and toggle behavior in real time without restarting, and [Simulate Failures and Edge Cases](./simulate-failures.md) and [Simulate Realistic Latency](./simulate-latency.md) extend any mock to cover the error paths and performance characteristics that real services exhibit.
As the mock grows, [Scenario Scripts](./scenario-scripts.md) let you automate repetitive REPL interactions — seeding data on startup, building reusable request sequences — while [Federated Context Files](./federated-context.md) and [Test the Context, Not the Handlers](./test-context-not-handlers.md) keep the stateful logic organized and reliable. [Live Server Inspection with the REPL](./repl-inspection.md) is Counterfact's most distinctive feature, letting you seed data, send requests, and toggle behavior in real time without restarting, and [Simulate Failures and Edge Cases](./simulate-failures.md), [Test Fault Scenarios with Chaos Rules](./test-fault-scenarios-with-chaos.md), and [Simulate Realistic Latency](./simulate-latency.md) extend any mock to cover the error paths and performance characteristics that real services exhibit.

When your project involves multiple versions or multiple specs, [Multiple API Versions](./multiple-versions.md) shows how to serve them from a shared set of route files using `$.minVersion()` to branch on version without duplicating handlers. For teams that want the mock to remain a reliable, long-lived artifact, [Reference Implementation](./reference-implementation.md) and [Automated Integration Tests](./automated-integration-tests.md) make it a first-class part of the codebase that can run in CI. Finally, [Agentic Sandbox](./agentic-sandbox.md) and [Hybrid Proxy](./hybrid-proxy.md) address the two common integration strategies — isolating an AI agent from the real service, or blending mock and live traffic — and [Custom Middleware](./custom-middleware.md) covers cross-cutting concerns like authentication and logging without touching individual handlers.

Expand All @@ -21,6 +21,7 @@ When your project involves multiple versions or multiple specs, [Multiple API Ve
| [Test the Context, Not the Handlers](./test-context-not-handlers.md) | You want to keep shared stateful logic reliable as the mock grows |
| [Live Server Inspection with the REPL](./repl-inspection.md) | You want to seed data, send requests, and toggle behavior without restarting the server |
| [Simulate Failures and Edge Cases](./simulate-failures.md) | You need reproducible, on-demand error conditions for development or testing |
| [Test Fault Scenarios with Chaos Rules](./test-fault-scenarios-with-chaos.md) | You want to inject HTTP-layer failures on demand using `chaos()` without editing handlers |
| [Simulate Realistic Latency](./simulate-latency.md) | You want to test how clients and UIs behave under realistic response times |
| [Reference Implementation](./reference-implementation.md) | You want a working, executable implementation that expresses intended API behavior in code |
| [Multiple API Versions](./multiple-versions.md) | You maintain multiple versions of an API and want shared handlers that adapt by version |
Expand Down
58 changes: 58 additions & 0 deletions docs/patterns/test-fault-scenarios-with-chaos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Test Fault Scenarios with Chaos Rules

You want to test how a client, UI, or integration behaves under upstream failures without editing route handlers or restarting the mock server.

## Problem

Failure behavior is often tested too late because reproducing 5xxs, flaky responses, or temporary outages usually means changing handler code, wiring custom flags, or waiting for real backend incidents.

## Solution

Use Counterfact's `chaos()` API from the Live REPL to inject HTTP-layer faults on demand. Keep the baseline handlers unchanged, then apply temporary rules for the exact paths and failure profiles you want to exercise.

## Example

Start with a healthy service:

```text
⬣> client.get("/payments/42")
{ status: 200, body: { ... } }
```

Inject an intermittent upstream failure pattern:

```ts
// Match /payments* requests indefinitely,
// but fail only about 20% with a retry hint.
chaos("/payments")
.probability(0.2)
.status(503)
.header("Retry-After", "1");
```

You can also target a bounded outage:

```ts
// Fail the next 3 matching requests, then stop automatically.
chaos("/payments").next(3).status(503);
```

And remove the rule when your test scenario is complete:

```ts
const fault = chaos("/payments").status(500);
fault.stop();
```

## Consequences

- Faults are injected at the HTTP response layer, so you can test resilience behavior without changing route files.
- Rules are fast to toggle from the REPL, which is useful for exploratory testing and manual acceptance checks.
- `probability(...)` enables controlled flakiness; `next(count)` enables deterministic burst failures.
- This pattern does not simulate low-level network disconnects; it focuses on HTTP response behavior.

## Related Patterns

- [Simulate Failures and Edge Cases](./simulate-failures.md) — implement failure behavior directly in handlers/context
- [Simulate Realistic Latency](./simulate-latency.md) — add delayed responses to complement fault injection
- [Live Server Inspection with the REPL](./repl-inspection.md) — drive chaos rules and requests interactively
123 changes: 123 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Complete reference for Counterfact's architecture, route handlers, and CLI.
- [Hybrid proxy](#hybrid-proxy)
- [Middleware](#middleware)
- [Type safety](#type-safety)
- [Chaos API (HTTP-layer fault injection)](#chaos-api-http-layer-fault-injection)
- [Programmatic API](#programmatic-api)
- [Multiple API versions](#multiple-api-versions)
- [CLI reference](#cli-reference)
Expand Down Expand Up @@ -350,6 +351,128 @@ See the [Multiple versions feature page](./features/multiple-versions.md) for a

---

## Chaos API (HTTP-layer fault injection)

The `chaos()` function lets you inject HTTP-layer faults into simulated responses without modifying your route handlers. It is available as a global in the [Live REPL](#live-repl) and can also be used programmatically.

### Quick start

```ts
// Fail the next 3 /orders requests with a 50% probability
const fault = chaos("/orders")
.next(3)
.probability(0.5)
.status(500)
.delay(1_000)
.transformBody((body) => ({ ...body, error: true }))
.header("Retry-After", "60");

// Pause / resume the rule at runtime
fault.stop();
fault.start();
```

### Creating a rule

```ts
chaos() // matches all paths (global rule)
chaos(pathPrefix) // matches paths that start with pathPrefix
```

Then set the scope:

| Method | Description |
|--------|-------------|
| `.next()` | Apply to the **next** matching response (once). |
| `.next(count)` | Apply to the next `count` matching responses. |

A newly created rule applies indefinitely by default, unless you restrict it with `next(...)`.

### Configuration methods

All configuration methods return `this` for fluent chaining and update the rule's recency (used for [multiple-rule selection](#multiple-matching-rules)).

| Method | Description |
|--------|-------------|
| `.probability(value)` | Probability `0`–`1` that the rule fires for an eligible response. Default `1`. |
| `.status(code)` | Override the HTTP status code. |
| `.delay(ms)` | Delay the response by `ms` milliseconds. |
| `.header(name, value)` | Set or replace a response header (except `Content-Type`, which is ignored). |
| `.removeHeader(name)` | Remove a response header if present (except `Content-Type`, which is ignored). |
| `.body(value)` | Replace the response body. |
| `.transformBody(fn)` | Transform the response body: `fn` receives the current body and returns the new one. |

### Lifecycle

```ts
fault.stop() // disable the rule (does not consume remaining count)
fault.start() // re-enable a stopped rule
```

A newly created rule starts **active** by default.

### Counting semantics

Only responses where the rule **actually fires** (after the probability check) decrement the remaining count. Stopped rules and probability-skipped responses do not decrement the count.

### Path prefix semantics

A rule matches when the request path **starts with** the configured prefix.

```ts
chaos("/orders")
// Matches: /orders, /orders/123, /orders/123/items
// Does not: /users, /inventory/orders
```

When `pathPrefix` is omitted (or `""`), the rule matches all paths.

### Multiple matching rules

When more than one active rule matches a request, exactly one is selected using this precedence:

1. **Longest matching prefix** wins.
2. Among rules with the same prefix length, the **most recently updated** active rule wins.

"Most recently updated" means the rule whose configuration or lifecycle state (`start`, `stop`, `next`, `probability`, `status`, `delay`, `header`, `removeHeader`, `body`, `transformBody`) was changed most recently.

### Examples

```ts
// Return 500 for the next request (any path)
chaos().next().status(500);

// Return 500 for the next 3 /orders requests
chaos("/orders").next(3).status(500);

// Always delay /orders requests by 1 second
chaos("/orders").delay(1_000);

// Inject a 429 with a Retry-After header for the next /orders request
chaos("/orders").next().header("Retry-After", "60").status(429);

// Add an error field to the response body for the next /orders request
chaos("/orders").next().transformBody((body) => ({
...body,
error: true,
}));
```

### Fault simulation pattern

`chaos()` is Counterfact's fault-injection API and is available as a global in the Live REPL.
Rules are active for every matching request by default; use `probability(...)` to decide whether each individual request actually fails.

```ts
// Evaluate this rule for every /payments request, but fail only ~20% with 503.
chaos("/payments")
.probability(0.2)
.status(503)
.header("Retry-After", "1");
```

---

## OpenAPI Overlays

[OpenAPI Overlays](https://spec.openapis.org/overlay/v1.0.0.html) let you apply targeted modifications to an OpenAPI document without editing the original file. Counterfact loads overlay files, evaluates their JSONPath targets against the spec, and applies each action before code generation and server startup.
Expand Down
5 changes: 5 additions & 0 deletions src/api-runner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { rm } from "node:fs/promises";

import type { Config } from "./server/config.js";
import { ChaosRegistry } from "./server/chaos.js";
import { ContextRegistry } from "./server/context-registry.js";
import { Dispatcher } from "./server/dispatcher.js";
import { loadOpenApiDocument } from "./server/load-openapi-document.js";
Expand Down Expand Up @@ -111,6 +112,7 @@ export class ApiRunner {
group: string,
version = "",
versions: readonly string[] = [],
chaosRegistry: ChaosRegistry,
) {
this.group = group;
this.version = version;
Expand Down Expand Up @@ -151,6 +153,7 @@ export class ApiRunner {
config,
version,
versions,
chaosRegistry,
);

this.transpiler = new Transpiler(
Expand Down Expand Up @@ -185,6 +188,7 @@ export class ApiRunner {
group = "",
version = "",
versions: readonly string[] = [],
chaosRegistry = new ChaosRegistry(),
): Promise<ApiRunner> {
const nativeTs = await runtimeCanExecuteErasableTs();

Expand Down Expand Up @@ -212,6 +216,7 @@ export class ApiRunner {
group,
version,
versions,
chaosRegistry,
);
}

Expand Down
5 changes: 5 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createHttpTerminator, type HttpTerminator } from "http-terminator";
import { ApiRunner } from "./api-runner.js";
import { startRepl as startReplServer } from "./repl/repl.js";
import { createRouteFunction } from "./repl/route-builder.js";
import { ChaosRegistry } from "./server/chaos.js";
import type { Config } from "./server/config.js";
import { ContextRegistry } from "./server/context-registry.js";
import { createKoaApp } from "./server/web-server/create-koa-app.js";
Expand Down Expand Up @@ -248,6 +249,8 @@ export async function counterfact(config: Config, specs?: SpecConfig[]) {
}
}

const chaosRegistry = new ChaosRegistry();

const runners = await Promise.all(
normalizedSpecs.map((spec) =>
ApiRunner.create(
Expand All @@ -262,6 +265,7 @@ export async function counterfact(config: Config, specs?: SpecConfig[]) {
spec.group,
spec.version ?? "",
versionsByGroup.get(spec.group) ?? [],
chaosRegistry,
),
),
);
Expand Down Expand Up @@ -394,6 +398,7 @@ export async function counterfact(config: Config, specs?: SpecConfig[]) {
registry: runner.registry,
scenarioRegistry: runner.scenarioRegistry,
})),
chaosRegistry,
),
};
}
7 changes: 7 additions & 0 deletions src/repl/repl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import repl from "node:repl";

import type { Config } from "../server/config.js";
import type { ChaosRegistry } from "../server/chaos.js";
import type { ContextRegistry } from "../server/context-registry.js";
import type { OpenApiDocument } from "../server/dispatcher.js";
import type { Registry } from "../server/registry.js";
Expand Down Expand Up @@ -97,7 +98,7 @@
}

const selectedGroup = args[0] ?? "";
const selectedRegistry = groupedScenarioRegistries[selectedGroup];

Check warning on line 101 in src/repl/repl.ts

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Variable Assigned to Object Injection Sink

if (selectedRegistry === undefined) {
const scenarioPartial = hasTrailingWhitespace
Expand Down Expand Up @@ -264,6 +265,7 @@
openApiDocument?: OpenApiDocument,
scenarioRegistry?: ScenarioRegistry,
apiBindings?: ReplApiBinding[],
chaosRegistry?: ChaosRegistry,
) {
const bindings =
apiBindings === undefined || apiBindings.length === 0
Expand Down Expand Up @@ -486,6 +488,11 @@
? Object.fromEntries(groupedBindings.map((binding) => [binding.key, {}]))
: {};

if (chaosRegistry !== undefined) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, again there should be a single chaosRegistry at the server level.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and already wired this way: there is a single server-level ChaosRegistry shared across all runners and the REPL. Addressed in commit 395d262, with cross-group behavior covered in 81e0ece.

replServer.context.chaos = (pathPrefix = "") =>
chaosRegistry.createRule(pathPrefix);
}

replServer.defineCommand("scenario", {
async action(text: string) {
sendTelemetry("repl_command_used", { command: "scenario" });
Expand Down Expand Up @@ -565,7 +572,7 @@
return;
}

const fn = module[functionName];

Check warning on line 575 in src/repl/repl.ts

View workflow job for this annotation

GitHub Actions / CI Checks (ubuntu-latest)

[security/detect-object-injection] Variable Assigned to Object Injection Sink

if (typeof fn !== "function") {
print(
Expand Down
Loading
Loading