fix(backoff): escalate across natural expiry so rate-limits actually back off #3
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "trevin/fix/backoff-escalation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Backoff windows never escalated — they stayed at 5s forever. The admin panel showed
failure_countclimbing (level 64) whileWindowremained5s, causing one wasted probe against a known-bad key every 5s indefinitely. This is the exact pathology §3's exponential curve was meant to prevent.Root cause
Design contradiction between two parts of the same feature:
record_failure(aor/backoff.py) escalated the window only when a failure arrived while still backed off (until_ts > now); a failure arriving after the window elapsed naturally reset to 5s.select_sequence(aor/routing.py§4 step 6) filters out keys whoseuntil_ts > nowat request entry, so a key is only retried after its backoff window elapses — i.e. precisely whenuntil_ts <= now.Every real failure therefore hit the reset-to-5s branch; the doubling path was unreachable through normal routing. The spec (
docs/requirements-sticky-backoff.md§3) explicitly codified the broken rule: "a failure that arrives after the previous window elapsed naturally resets the window to 5s (the key had its chance to recover)." That recovery assumption is false when combined with backoff filtering — natural expiry just means the router waited out the window, not that the key recovered. The only true recovery signal is a success, which alreadyclear()s the row.test_escalation_doubles_until_caponly passed because it manually drove failures within the window — something the router never does.Fix
record_failurenow keys escalation on row existence, not on window expiry:row is None(first failure, or after a success cleared the row) → fresh 5s windowrow exists(no success has cleared it → key has not recovered) → double the window, capped at 24hSuccess still calls
backoff.clear(), so the recovery path is unchanged. A transient flake that succeeds in between still resets to 5s on the next failure.Behavior after fix
No changes to
routing.py,proxy.py,stats.py,admin.py, or templates — none depend on the reset semantics; they only readwindow_s/failure_countfor display and useis_backed_offfor filtering.Testing
uv run ruff check .— passuv run ruff format --check .— passuv run pytest -q— 135 passedRewrote the two tests that encoded the buggy behavior (
test_natural_expiry_resets_window_to_initial,test_record_failure_at_exact_expiry_resets_to_initial) to assert escalation continues across natural and exact expiry.test_escalation_doubles_until_capandtest_clear_drops_row_on_successpass unchanged.Spec
Updated
docs/requirements-sticky-backoff.md§3 to correct the "natural expiry resets to 5s" wording, with a note explaining why the old rule combined with §4-step-6 filtering to make escalation unreachable.