Request Lifecycle

This page is the technical deep-dive for how each request is processed.

Request lifecycle

Every HTTP request that reaches the enforcement point goes through the following steps in order.

1. Context extraction

build_request_context() assembles a normalized view of the request:

  • Host — request host used by selector hosts filtering
  • Headers — stored with three key variants per header: as-is, underscore form, and hyphen form, so X-API-Key, x_api_key, and x-api-key all resolve correctly
  • JWT payload — the Authorization: Bearer <token> header is base64url-decoded (payload only, no signature verification)
  • IP address — taken from ngx.var.remote_addr
  • User-Agent — read lazily only when active policy keys require ua:* descriptors
  • Query parameters — parsed from ngx.var.query_string

2. Bundle check

If no bundle is loaded yet (edge is still starting), the request is rejected with 503 Service Unavailable and reason no_bundle_loaded.

3. Runtime overrides and kill switch scan

Before the kill-switch scan, runtime override blocks are checked:

  • global_shadow active -> all matched policies are treated as shadow mode
  • kill_switch_override active -> kill-switch scan is skipped

If kill_switch_override is not active, kill switches are evaluated before any route matching.

The engine scans every entry in bundle.kill_switches:

  1. If the entry has expires_at and that time is past, the entry is skipped.
  2. The descriptor for the entry’s scope_key is extracted from the request context.
  3. If the descriptor value equals scope_value (exact match), and the optional route field also matches — the request is immediately rejected with reason: kill_switch and a Retry-After: 3600 header.

First match wins.

4. Route matching

route_index.match(host, method, path) matches selectors in two stages:

  • Host filter (selector.hosts) is evaluated first.
  • pathPrefix selectors collect matches at every level of the trie.
  • pathExact selectors match only at the leaf node.
  • methods filters are applied per-policy after trie traversal.

If no policies match, the request is allowed with reason: no_matching_policy.

5. Per-policy evaluation

For each matched policy (in index order):

5a. Shadow mode detection

If policy.spec.mode == "shadow" or top-level global_shadow is active, limiter state for this policy is namespaced under a shadow: prefix. A would-reject is recorded but traffic is never blocked.

5b. Loop detection

If loop_detection.enabled, a fingerprint is computed from request inputs and descriptors. A counter is incremented in shared dict with TTL = window_seconds. If the counter exceeds threshold_identical_requests, the configured action (reject / throttle / warn) is applied.

5c. Circuit breaker

If circuit_breaker.enabled, spend rate for the policy key is checked. If breaker is open, request is rejected with reason: circuit_breaker_open.

5d. Rule matching

Rules are evaluated in definition order. Each rule may have a match block that filters by descriptor equality. If no rules match and fallback_limit exists, fallback is used.

5e. Per-rule limiter

The algorithm in rule.algorithm is invoked:

Algorithm Module State key prefix
token_bucket token_bucket.lua tb:
cost_based cost_budget.lua cb:
token_bucket_llm llm_limiter.lua tpm: / tpd:

All limiters use ngx.shared.dict (fairvisor_counters) for state.

If any limiter denies the request, evaluation stops and reject is returned.

6. Allow or reject

  • Allow: 200 OK (decision service) or pass-through (reverse proxy). Rate-limit headers may be set.
  • Reject: 429 Too Many Requests with:
    • X-Fairvisor-Reason
    • Retry-After (deterministic per-identity jitter)
    • RateLimit, RateLimit-Reset
    • optionally RateLimit-Limit, RateLimit-Remaining
  • Throttle: worker sleeps delay_ms (max 30s), then allows.

Policy/rule attribution is available via debug-session headers (X-Fairvisor-Debug-*), not standard reject headers.

Fail-open semantics

Condition Behaviour
No bundle loaded 503 (intentional; misconfiguration signal)
ngx.shared.dict write error Allow (logged as warning)
Missing descriptor key Rule is skipped; warning logged
JSON parse error in body Token estimation falls back to body_length / 4
SaaS unreachable Last known bundle remains active

Evaluation order summary

Request in
  -> Runtime overrides check
       -> kill_switch_override active? skip kill-switch scan
  -> Kill switches (first match -> 429, when enabled)
  -> Route index match (host + method + path)
       -> no match -> allow
  -> For each matched policy:
       -> loop detection (optional)
       -> circuit breaker (optional)
       -> for each matching rule:
            -> limiter check
                 -> deny -> 429 (or shadow-allow)
                 -> allow -> continue
  -> all policies passed -> allow