featherbit 0.10.0
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_countstore-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_tracesdefaults to 1000 (was 50). Debug mode is off by default, so only deliberate users are affected.connect_budget_msdefaults 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_entriesdefaults to 10,000. Apolicy: localcache that was previously unbounded is now capped; raise it if your working set is larger.- A
scriptwhose top level legitimately takes longer thantimeout_msto load now fails to compile. Pathological at the 5000ms default; raise the budget or move the work intoexecute. - 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-cachehalf 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.