Shadow Mode

Shadow mode runs the full enforcement logic on every request but never blocks traffic. Instead it records what would have happened and emits would_reject signals that you can observe in logs and metrics.

Shadow mode is designed for:

  • Canary rollout of a new policy — validate it’s correct before enforcing
  • Capacity planning — measure how often a limit would fire in production
  • Debugging — understand why a policy unexpectedly rejects traffic

Enabling shadow mode

Set spec.mode to "shadow" on any policy:

{
  "id": "candidate-rate-limit",
  "spec": {
    "mode": "shadow",
    "selector": { "pathPrefix": "/api/v2/" },
    "rules": [{
      "name": "new-per-org-limit",
      "limit_keys": ["jwt:org_id"],
      "algorithm": "token_bucket",
      "algorithm_config": {
        "tokens_per_second": 50,
        "burst": 100
      }
    }]
  }
}

The default value is "enforce".

You can also activate incident-wide shadow behavior with top-level global_shadow in the bundle (see Bundle Structure).

How it works

When a policy’s mode is shadow:

  1. All limiters (token bucket, cost budget, LLM limiter) are invoked normally
  2. Shared dict keys are namespaced with a shadow: prefix, so shadow counters are isolated from production counters
  3. If a limiter returns denied, the decision is wrapped: action is set to allow, would_reject = true is set, and the original rejection reason is preserved as original_reason
  4. The response to the client is 200 OK — no Retry-After or X-Fairvisor-Reason headers are set
  5. Log lines contain "mode":"shadow" and "would_reject":true

Counter isolation means shadow mode policies accumulate spend independently and do not share state with enforce-mode policies covering the same paths.

Log output

{
  "action": "allow",
  "mode": "shadow",
  "would_reject": true,
  "original_reason": "token_bucket_exceeded",
  "policy_id": "candidate-rate-limit",
  "rule_name": "new-per-org-limit"
}

Filtering shadow would-rejects

docker logs fairvisor -f \
  | fairvisor logs --action=allow \
  | jq 'select(.would_reject == true)'

Promoting a shadow policy to enforce

  1. Observe the policy in shadow mode until you’re satisfied the threshold is correct
  2. Increment bundle_version
  3. Change mode from "shadow" to "enforce"
  4. Deploy the updated bundle

Shadow counters are isolated, so they will not carry over. The enforce policy starts with fresh counters.

Shadow mode and loop detection

Loop detection in shadow mode uses the same shadow: key prefix for its fingerprint counters. Would-detects are logged but traffic is not throttled or rejected.

Shadow mode and circuit breaker

The circuit breaker in a shadow-mode policy checks and updates shadow-namespaced rate keys. If the breaker would have tripped, would_reject = true is set but no request is blocked.

Global shadow override (incident mode)

global_shadow is a top-level bundle override that temporarily forces all matched policies to behave like shadow mode:

{
  "global_shadow": {
    "enabled": true,
    "reason": "incident-2026-02-20",
    "expires_at": "2026-02-20T19:00:00Z"
  }
}

Key properties:

  • TTL-based: expires automatically at expires_at
  • Runtime-only behavior: policy objects do not need to be rewritten
  • Client response headers are unchanged; use logs and metrics to confirm active state