rproxy: circuit breaker for asks to waf (closes #392)
check / check (push) Failing after 6m19s

Five failed asks to waf within five seconds open the breaker. An ask
fails when it gets no answer in time, slow answers included, or an
answer rproxy cannot pass on. Every usable answer clears the failures
and closes the breaker. While open, a request takes
SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION without asking waf, except
one ask a second. SPEC.md describes it.

The sww test that stops and restarts waf now waits for the breaker to
close before it expects the ban again.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-15 15:30:29 +00:00
1 parent 8efd6339a6
commit 37419716ea
11 files changed
+473 -28

No files matched your search

+38 -14
View File
@@ -2583,11 +2583,11 @@ request and ban notes name the site by its host.
## `rproxy`
`sww rproxy` is the reverse proxy in front of one app. It asks `waf` about each
request, and passes on what `waf` allows. It keeps nothing between requests and
writes nothing to disk. When `waf` cannot be connected to, does not answer at
all, or does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`,
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides. Its default, `allow`,
keeps the site answering.
request, and passes on what `waf` allows. It writes nothing to disk. The only
thing it keeps between requests is its circuit breaker for asks to `waf`.
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with a
request when `waf` gives no answer in time, and while the circuit breaker is
open. Its default, `allow`, keeps the site answering.
`rproxy` reads only its `SWW_RPROXY_` settings, which `docs/settings.md` lists.
One of them is required: `SWW_RPROXY_WAF_BACKEND`, `waf`'s URL. It has no
@@ -2605,7 +2605,8 @@ only. The image runs it by default (see "Deployment").
4. It refuses a request with a method it does not pass on, or one it cannot put
to `waf` as the visitor sent it (see "Requests `rproxy` refuses itself").
5. It reads up to the first 64 KiB of the body into memory.
6. It sends `waf` the question (see "The question"). It speaks HTTP/1.1 to
6. It sends `waf` the question (see "The question"), unless the circuit breaker
keeps it from asking (see "The circuit breaker"). It speaks HTTP/1.1 to
`waf`, whatever `waf` offers. Go's HTTP/2 client would refuse to send a
visitor's `Upgrade` header, and most `Connection` headers. The question never
carries the visitor's `Trailer`.
@@ -2679,23 +2680,24 @@ log line has status `0`.
### Without an answer from `waf`
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with the
request only when `waf`:
request when `waf`:
- cannot be connected to;
- does not answer at all;
- does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, which covers
connecting, sending the question and reading the whole answer.
An answer whose body breaks off counts as no answer at all.
An answer whose body breaks off counts as no answer at all. The setting also
decides, without asking `waf`, while the circuit breaker is open.
`allow`, the default, passes the request to the app. `deny` answers `503`.
Either way `rproxy` writes a process warning, its log line has
`waf_backend_no_answer`, and it sends no report.
A visitor can make `waf` slow. A request that keeps `waf` busy past
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, like any request while `waf` is overloaded,
gets `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. With `deny`, such a request
gets `503` instead of reaching the app.
`waf` taking longer than `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` to decide a request
is a bug in `waf`. Such a request gets
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`, like any request while `waf` is
overloaded. With `deny`, it gets `503` instead of reaching the app.
A path under `/_sww/` that `allow` would pass on is answered `404` instead, so
that the app never sees that prefix.
@@ -2710,6 +2712,28 @@ A visitor that goes away while `waf` is being asked is no failure of `waf`.
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` does not apply. Its log line has
status `0` and no `disposition`.
### The circuit breaker
`rproxy` stops asking `waf` for a while when `waf` keeps failing to answer. An
ask fails when `waf` gives no answer in time (see "Without an answer from
`waf`"), or an answer `rproxy` cannot pass on. Any other answer in time
succeeds, whatever it decides. A visitor that goes away while `waf` is being
asked counts as neither.
The breaker works like this:
- It opens when 5 asks fail within 5 seconds. Fewer failures never open it.
- While it is open, a request gets
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` at once, without asking `waf`.
It gets the same process warning and `waf_backend_no_answer` as a request
whose ask failed.
- While it is open, one request a second still asks `waf`.
- Each ask that succeeds clears the failures counted so far, and closes the
breaker if it is open.
The breaker lives in memory, and a restart of `rproxy` closes it. It has no
settings.
### Passing a request to the app
`rproxy` sends the app at `SWW_RPROXY_UPSTREAM_URL` the visitor's request: the
@@ -2983,8 +3007,8 @@ host directory, keeps them across deploys. A host directory mounted there needs
no change of owner, and files an earlier owner left in it can be read and
replaced. The volume holds a few tens of MiB at most with the defaults, and
needs no backup beyond the host's own. The image declares no volume, since every
app image built on it would inherit it. `rproxy` keeps nothing, and needs no
volume.
app image built on it would inherit it. `rproxy` writes nothing to disk, and
needs no volume.
`waf` keeps its cache files in `SWW_WAF_CACHE_DIR`, `/var/cache/sww` by default,
which the `run` script makes. The cache directory needs no volume and can be