Skip to content

featherbit 0.10.0

Choose a tag to compare

@francescodedomenico francescodedomenico released this 19 Sep 19:02
· 53 commits to main since this release

Minor release. Policies can remember things — four store-* nodes over the shared redis/valkey stores — and proxy-cache can share one cache across instances. Plus two availability fixes where the gateway could previously hang or fail open.

Policies can hold state

store-get, store-set, store-incr and store-delete read and write arbitrary keys in a declared stores: entry. Until now the only cross-request store a policy had was a cookie — client-side, size-limited, spoofable, per-browser.

- id: count-retry
  type: store-incr
  config:
    store: sessions
    key: "oidc-retry:{{client.ip}}"
    ttl_seconds: 300
    name: retry_count

store-get declares a miss outcome port, so "the key isn't there" is a branch the compiler makes you wire rather than something you discover at runtime. It is deliberately distinct from error: a store outage must never look like "nothing recorded", or a policy takes its happy path during exactly the incident where that is most wrong.

store-incr's TTL applies when the key is created and is never refreshed, so a client that keeps retrying cannot hold its own bound open. refresh_ttl: true opts into the sliding window instead, and store-get's extend_ttl_seconds turns a read into a keep-alive (GETEX), for state that should expire a fixed time after last use.

Keys live under a kv: namespace. A registry now declares every namespace featherbit writes — cnt, acme, sess, lock, subj, kv, cache — with a test that calls each subsystem's real key builder, so a future subsystem cannot quietly collide with one already in use.

A shared response cache

proxy-cache takes policy: local | redis + store:. Every instance used to keep its own cache, so three instances meant three cold caches and three times the upstream load for one working set — scaling out lowered the hit rate.

This cache fails open, unlike everything else that touches a store. A backend it cannot reach is a miss, and the request goes to the upstream. Sessions and the store-* nodes fail closed because losing their store loses correctness; a cache only holds a copy of something the upstream can produce again, so losing it should cost latency and nothing else. Failing closed would let a redis blip take down the highest-traffic routes it was only ever meant to accelerate.

Because that is silent, gateway_cache_events_total{backend,store,event} reports hits, misses, errors, evictions and size-skips. hit+miss partition every lookup, so the hit rate is hits/(hits+misses); error is an overlay on the miss it caused.

A pair whose halves disagree about policy or store is now rejected at compile time. It used to compile and serve a permanent 100% miss with no error anywhere.

Two things that could hang or fail open

A runaway Lua script no longer pins a worker. The script node parsed timeout_ms, stored it, and never enforced it — the field literally carried #[allow(dead_code)]. Since the Lua call is synchronous inside an async plugin, while true do end pinned the tokio worker polling it. Now bounded by a Luau VM interrupt; the same budget also bounds policy-compile validation, where a top-level loop previously hung the Admin API.

A store outage no longer hangs a request. connect_timeout_ms bounded one attempt, not the retry schedule around it — 6 retries with uncapped backoff. Measured against a refused connection: 17.96 seconds before the error surfaced, on the first request after an outage began. connect_budget_ms (default 5000) bounds connect plus retries plus waits together, for every store consumer.

Also

reads_response_body now answered per configured instance. A traffic-label matching on resp_body used to silently stop matching on a streaming route; the 16 log_format loggers no longer force buffering when their format never mentions the body
Trace listings carry a retention block (truncated, evicted, oldest_seq), so an empty filtered result is distinguishable from one that rotated out. max_traces default 50 → 1000
Local response cache bounded by cache.max_entries (default 10,000). It previously evicted an expired entry only when something read it, so anything written and never read again was kept for the life of the process
Editor the sidebar shows one library at a time — routes get the whole body instead of a quarter
Clipboard copy works outside a secure context; navigator.clipboard is undefined over plain HTTP and threw a TypeError

Upgrade notes

  • max_traces defaults to 1000 (was 50). Debug mode is off by default, so only deliberate users are affected.
  • connect_budget_ms defaults to 5000. A store that previously took longer than that to connect now fails instead of eventually succeeding. Raise it if you are on a slow link.
  • cache.max_entries defaults to 10,000. A policy: local cache that was previously unbounded is now capped; raise it if your working set is larger.
  • A script whose top level legitimately takes longer than timeout_ms to load now fails to compile. Pathological at the 5000ms default; raise the budget or move the work into execute.
  • No config changes are required otherwise. Existing policies compile and behave as before.

Known limitations

  • No explicit cache invalidation — entries expire, nothing purges on demand.
  • Two-tier caching (local in front of redis) is out of scope; coherence is its own design.
  • A proxy-cache half with no counterpart is reported, not rejected — it is also what a half-built policy looks like.
  • Request bodies are still fully buffered; streaming uploads remain separate work.

Verification

cargo test 1377 passed with a live redis, without it, and in --release · both clippy invocations clean (--locked -- -D warnings, and --no-default-features) · cargo fmt --check clean · UI lint, tsc -b, 194 vitest · website build clean · e2e 152 passed / 1 skipped.

Every load-bearing behaviour in this release was demonstrated to fail against the implementation it rules out — the cross-instance cache sharing, the store-incr TTL rule, the cache size guard, the eviction policy, and the script timeout — by mutating the code and observing the test catch it, not by reading the diff.