> what happens if SWW_RPROXY_WAF_BACKEND is unset? in the case of the waf backend being down, is there a circuit breaker so that all requests aren't delayed while it down?
Task
Answer both questions in one comment here, plainly, from the code at the head of next, with file and line for each claim. No code change and no PR.
What rproxy does when SWW_RPROXY_WAF_BACKEND is unset: refuses to start, runs with no WAF at all, or something else; and what it logs.
When the WAF is down: how each failure mode behaves today (connection refused, connection hangs, slow answers), how long one request waits in each, and whether anything stops asking the WAF for a while after failures (a circuit breaker) or every request pays the wait.
If there is no circuit breaker: what the simplest one would be (when it opens, how long it stays open, how it closes again, which settings if any), and what it costs. Recommend yes or no.
Definition of done
The comment is posted. The issue is then assigned to sneak if a circuit breaker is recommended, as his decision; otherwise it is closed.
Model: opus-5-5
sneak, chat, 14 November 2026:
> what happens if SWW_RPROXY_WAF_BACKEND is unset? in the case of the waf backend being down, is there a circuit breaker so that all requests aren't delayed while it down?
## Task
Answer both questions in one comment here, plainly, from the code at the head of `next`, with file and line for each claim. No code change and no PR.
1. What `rproxy` does when `SWW_RPROXY_WAF_BACKEND` is unset: refuses to start, runs with no WAF at all, or something else; and what it logs.
2. When the WAF is down: how each failure mode behaves today (connection refused, connection hangs, slow answers), how long one request waits in each, and whether anything stops asking the WAF for a while after failures (a circuit breaker) or every request pays the wait.
3. If there is no circuit breaker: what the simplest one would be (when it opens, how long it stays open, how it closes again, which settings if any), and what it costs. Recommend yes or no.
## Definition of done
The comment is posted. The issue is then assigned to sneak if a circuit breaker is recommended, as his decision; otherwise it is closed.
Model: opus-5-5
SWW_RPROXY_WAF_BACKEND unset: rproxy refuses to start.
internal/config/rproxy.go:152-164: an unset or empty value records the error is required: it is waf's URL, such as http://waf:8080 (internal/config/rproxy.go:64-65). The _FILE form naming an empty file ends the same way.
internal/config/rproxy.go:111-113: any invalid setting fails the whole read, and only the first is reported (internal/config/config.go:136-141). This setting is read fourth, so a bad listen address, upstream URL or trusted-proxy list is reported ahead of it, one per start.
internal/sww/rproxy.go:31-36: one process line on stdout, JSON, level ERROR, msginvalid setting, errorSWW_RPROXY_WAF_BACKEND: is required: it is waf's URL, such as http://waf:8080, with typeprocess and instance the host's name (internal/requestlog/requestlog.go:302-323). RunRProxy returns 1, Main returns it (internal/sww/sww.go:92-93) and the process exits with it (cmd/sww/main.go:19). Nothing listens. There is no mode that runs without a WAF.
The container health check reads only the listen address and the upstream URL (internal/config/rproxy.go:120-135), so it does not need the setting.
waf down: there is no circuit breaker. Every request asks waf, and pays whatever the ask costs.
One request (internal/rproxy/rproxy.go:199-201, internal/rproxy/waf.go:92-131) asks under one deadline, SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, default 2 s, set at internal/rproxy/waf.go:93. It covers resolving the name, connecting, sending the question and reading the whole answer. The transport that carries questions (internal/rproxy/rproxy.go:145-164) sets no dial timeout, no response-header timeout and no per-host connection limit, so that deadline is the only bound. Go's http.Transport without a DialContext dials with a zero net.Dialer, whose default is no timeout (net/http/transport.go:1303-1320 and net/dial.go:127-131 in Go 1.26).
Per failure mode:
Connection refused, or the name no longer resolves (the usual case on one Docker network when the waf container is stopped or restarting): the dial fails at once. The request waits a few milliseconds, then takes SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION. No visible delay: with allow the site runs unprotected at full speed, with deny it answers 503 at once.
Connection hangs (packets dropped: waf on a host that is down or unreachable, or a waf whose accept queue is full): the dial waits the whole 2 s. Every request waits 2 s, each in parallel with the others (no per-host connection limit, so none queues behind another), then takes the disposition.
Slow answers (waf reachable but overloaded, stuck, or kept busy by the request itself): the request waits up to 2 s for the complete answer. An answer within 2 s is used as normal. Past 2 s, the disposition.
After each failure (internal/rproxy/waf.go:57-87, internal/rproxy/request.go:414-471): one process warning waf gave no answer per request, waf_backend_no_answer: true on the request's log line, no report, and with allow the request goes to the app, with deny the visitor gets 503. rproxy keeps nothing between requests (internal/rproxy/rproxy.go:9, SPEC.md:2585; the handler at internal/rproxy/rproxy.go:122-140 holds config, logs, two transports and the report URL, no failure state), so the next request asks again. GET /_sww/healthz is answered without asking waf (internal/rproxy/rproxy.go:176-180), so rproxy stays healthy throughout. Reports wait the same 2 s each, on their own goroutine (internal/rproxy/waf.go:229-285); no visitor waits for one.
In short: a waf that is down costs each request nothing noticeable. A waf that is unreachable or hung costs every request the full 2 s for as long as that lasts, plus one warning line per request.
Circuit breaker: the simplest one, and why I recommend against it.
The simplest breaker is one openUntil time on the handler, under a mutex:
It opens when a question ends in a dial failure: refused, no such host, unreachable, dial timed out.
While open, a request takes SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION without asking, with the same warning and log field.
It stays open a fixed short time, say 1 s. The first request after that asks waf; a usable answer closes it, a dial failure opens it again. No settings.
It needs a connect timeout on the WAF transport (a constant well under the backend timeout, say 500 ms), so that a hang shows up as a dial failure and not as context deadline exceeded, which is also what a slow answer gives.
Its cost: rproxy's first state between requests (SPEC promises none); up to the open time more of unprotected traffic (allow) or 503s (deny) after waf recovers; a probe request per open period; changes to SPEC.md, docs/settings.md, the README, and tests for the closed, open and probe states.
The catch:
It must never open on a slow answer. A visitor can make waf slow (SPEC.md:2694; the 16,000-cookie request in #182). A breaker that opens on a timeout lets one visitor switch the WAF off for everyone for the open period (allow), or take the site down with one slow request per period (deny). That is a WAF bypass or a cheap denial of service, and it is what a breaker library gives by default.
Opening only on dial failures, it saves the 2 s only in the hang case. Refused costs nothing to save. For a slow answer nothing safe can save it: the 2 s is the designed bound, and keeping waf's answers well inside it is what the issue above built.
Recommendation: no circuit breaker. If the hang case matters (a waf on another host), the one change I would make is the connect timeout alone: a constant, say 500 ms, on the WAF transport. It cuts a hung dial from 2 s to 500 ms per request, keeps rproxy stateless, and nothing a visitor sends can trigger it. Not filed; say so and it becomes its own issue.
Not checked: live behaviour against a stopped or unreachable waf. The waits above come from the code and Go's documented defaults, not from a measurement. Confidence: high on the start-up and per-request paths; medium on "a few milliseconds" for a refused connection, which depends on the network.
Closing, as the definition of done says when no breaker is recommended.
Model: fable-5-1
Answers from the head of `next` (`53d9bf6`).
**`SWW_RPROXY_WAF_BACKEND` unset: `rproxy` refuses to start.**
- `internal/config/rproxy.go:152-164`: an unset or empty value records the error `is required: it is waf's URL, such as http://waf:8080` (`internal/config/rproxy.go:64-65`). The `_FILE` form naming an empty file ends the same way.
- `internal/config/rproxy.go:111-113`: any invalid setting fails the whole read, and only the first is reported (`internal/config/config.go:136-141`). This setting is read fourth, so a bad listen address, upstream URL or trusted-proxy list is reported ahead of it, one per start.
- `internal/sww/rproxy.go:31-36`: one process line on stdout, JSON, level `ERROR`, `msg` `invalid setting`, `error` `SWW_RPROXY_WAF_BACKEND: is required: it is waf's URL, such as http://waf:8080`, with `type` `process` and `instance` the host's name (`internal/requestlog/requestlog.go:302-323`). `RunRProxy` returns 1, `Main` returns it (`internal/sww/sww.go:92-93`) and the process exits with it (`cmd/sww/main.go:19`). Nothing listens. There is no mode that runs without a WAF.
- The container health check reads only the listen address and the upstream URL (`internal/config/rproxy.go:120-135`), so it does not need the setting.
**`waf` down: there is no circuit breaker. Every request asks `waf`, and pays whatever the ask costs.**
One request (`internal/rproxy/rproxy.go:199-201`, `internal/rproxy/waf.go:92-131`) asks under one deadline, `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, default 2 s, set at `internal/rproxy/waf.go:93`. It covers resolving the name, connecting, sending the question and reading the whole answer. The transport that carries questions (`internal/rproxy/rproxy.go:145-164`) sets no dial timeout, no response-header timeout and no per-host connection limit, so that deadline is the only bound. Go's `http.Transport` without a `DialContext` dials with a zero `net.Dialer`, whose default is no timeout (`net/http/transport.go:1303-1320` and `net/dial.go:127-131` in Go 1.26).
Per failure mode:
- Connection refused, or the name no longer resolves (the usual case on one Docker network when the `waf` container is stopped or restarting): the dial fails at once. The request waits a few milliseconds, then takes `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. No visible delay: with `allow` the site runs unprotected at full speed, with `deny` it answers `503` at once.
- Connection hangs (packets dropped: `waf` on a host that is down or unreachable, or a `waf` whose accept queue is full): the dial waits the whole 2 s. Every request waits 2 s, each in parallel with the others (no per-host connection limit, so none queues behind another), then takes the disposition.
- Slow answers (`waf` reachable but overloaded, stuck, or kept busy by the request itself): the request waits up to 2 s for the complete answer. An answer within 2 s is used as normal. Past 2 s, the disposition.
After each failure (`internal/rproxy/waf.go:57-87`, `internal/rproxy/request.go:414-471`): one process warning `waf gave no answer` per request, `waf_backend_no_answer: true` on the request's log line, no report, and with `allow` the request goes to the app, with `deny` the visitor gets `503`. `rproxy` keeps nothing between requests (`internal/rproxy/rproxy.go:9`, `SPEC.md:2585`; the handler at `internal/rproxy/rproxy.go:122-140` holds config, logs, two transports and the report URL, no failure state), so the next request asks again. `GET /_sww/healthz` is answered without asking `waf` (`internal/rproxy/rproxy.go:176-180`), so `rproxy` stays healthy throughout. Reports wait the same 2 s each, on their own goroutine (`internal/rproxy/waf.go:229-285`); no visitor waits for one.
In short: a `waf` that is down costs each request nothing noticeable. A `waf` that is unreachable or hung costs every request the full 2 s for as long as that lasts, plus one warning line per request.
**Circuit breaker: the simplest one, and why I recommend against it.**
The simplest breaker is one `openUntil` time on the handler, under a mutex:
- It opens when a question ends in a dial failure: refused, no such host, unreachable, dial timed out.
- While open, a request takes `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` without asking, with the same warning and log field.
- It stays open a fixed short time, say 1 s. The first request after that asks `waf`; a usable answer closes it, a dial failure opens it again. No settings.
- It needs a connect timeout on the WAF transport (a constant well under the backend timeout, say 500 ms), so that a hang shows up as a dial failure and not as `context deadline exceeded`, which is also what a slow answer gives.
Its cost: `rproxy`'s first state between requests (SPEC promises none); up to the open time more of unprotected traffic (`allow`) or `503`s (`deny`) after `waf` recovers; a probe request per open period; changes to `SPEC.md`, `docs/settings.md`, the README, and tests for the closed, open and probe states.
The catch:
- It must never open on a slow answer. A visitor can make `waf` slow (`SPEC.md:2694`; the 16,000-cookie request in https://git.eeqj.de/sneak/smallwebwaf/issues/182). A breaker that opens on a timeout lets one visitor switch the WAF off for everyone for the open period (`allow`), or take the site down with one slow request per period (`deny`). That is a WAF bypass or a cheap denial of service, and it is what a breaker library gives by default.
- Opening only on dial failures, it saves the 2 s only in the hang case. Refused costs nothing to save. For a slow answer nothing safe can save it: the 2 s is the designed bound, and keeping `waf`'s answers well inside it is what the issue above built.
Recommendation: no circuit breaker. If the hang case matters (a `waf` on another host), the one change I would make is the connect timeout alone: a constant, say 500 ms, on the WAF transport. It cuts a hung dial from 2 s to 500 ms per request, keeps `rproxy` stateless, and nothing a visitor sends can trigger it. Not filed; say so and it becomes its own issue.
Not checked: live behaviour against a stopped or unreachable `waf`. The waits above come from the code and Go's documented defaults, not from a measurement. Confidence: high on the start-up and per-request paths; medium on "a few milliseconds" for a refused connection, which depends on the network.
Closing, as the definition of done says when no breaker is recommended.
Model: fable-5-1
A circuit breaker in this instance shouldn't open when a single request fails. It should require multiple failed requests over maybe five seconds. And then it should open until a request succeeds. And then it should or rather it should stay open until a request succeeds. At which point a single request succeeding should cause it to close again. When I say succeeding, I don't mean obviously approving the page from the decider, I mean the decider giving any answer uh whatsoever in a timely fashion.
A circuit breaker in this instance shouldn't open when a single request fails. It should require multiple failed requests over maybe five seconds. And then it should open until a request succeeds. And then it should or rather it should stay open until a request succeeds. At which point a single request succeeding should cause it to close again. When I say succeeding, I don't mean obviously approving the page from the decider, I mean the decider giving any answer uh whatsoever in a timely fashion.
Reading of sneak's comment above, for him to confirm before any code is written:
rproxy counts asks to waf that get no answer in time: refused, unreachable, or no complete answer within the backend timeout. Any answer in time counts as success, whatever it decides.
The breaker opens after 5 such failures within 5 seconds. One failure never opens it.
While open, requests take SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION at once, without asking waf. One request a second still asks; the first answer in time closes the breaker.
No new settings: 5 failures, 5 seconds and one ask a second are constants.
The catch: a visitor can make waf slow on purpose (the 16,000-cookie request in #182). Five slow requests in five seconds would open the breaker. With allow, that turns the WAF off for every visitor until an answer comes back in time; with deny, it answers 503 to everyone. Recommendation: count only failures to connect (refused, unreachable, connect timed out) toward opening, and add the 500 ms connect timeout so a hung host shows up as one. Slow answers then cost only the request that caused them.
Held as needs-decision until he confirms.
Model: opus-5-5
Reading of sneak's comment above, for him to confirm before any code is written:
- `rproxy` counts asks to `waf` that get no answer in time: refused, unreachable, or no complete answer within the backend timeout. Any answer in time counts as success, whatever it decides.
- The breaker opens after 5 such failures within 5 seconds. One failure never opens it.
- While open, requests take `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` at once, without asking `waf`. One request a second still asks; the first answer in time closes the breaker.
- No new settings: 5 failures, 5 seconds and one ask a second are constants.
The catch: a visitor can make `waf` slow on purpose (the 16,000-cookie request in https://git.eeqj.de/sneak/smallwebwaf/issues/182). Five slow requests in five seconds would open the breaker. With `allow`, that turns the WAF off for every visitor until an answer comes back in time; with `deny`, it answers `503` to everyone. Recommendation: count only failures to connect (refused, unreachable, connect timed out) toward opening, and add the 500 ms connect timeout so a hung host shows up as one. Slow answers then cost only the request that caused them.
Held as `needs-decision` until he confirms.
Model: opus-5-5
sneak
was assigned by clawbot2026-11-15 14:08:40 +01:00
If a visitor can perform a denial of service attack on the server, that's a bug in the server. We need to make sure that any requests that a visitor sends can be decided within the timeout. If they cannot, that is a bug in the decider.
If a visitor can perform a denial of service attack on the server, that's a bug in the server. We need to make sure that any requests that a visitor sends can be decided within the timeout. If they cannot, that is a bug in the decider.
Ruling recorded: slow answers count as failures; a request waf cannot decide in time is a bug in waf. The breaker is filed for a worker as #392, with the constants (5 failures in 5 s, one ask a second while open) stated there as reversible choices. Closing this question.
Model: opus-5-5
Ruling recorded: slow answers count as failures; a request `waf` cannot decide in time is a bug in `waf`. The breaker is filed for a worker as https://git.eeqj.de/sneak/smallwebwaf/issues/392, with the constants (5 failures in 5 s, one ask a second while open) stated there as reversible choices. Closing this question.
Model: opus-5-5
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
sneak, chat, 14 November 2026:
> what happens if SWW_RPROXY_WAF_BACKEND is unset? in the case of the waf backend being down, is there a circuit breaker so that all requests aren't delayed while it down?
Task
Answer both questions in one comment here, plainly, from the code at the head of
next, with file and line for each claim. No code change and no PR.rproxydoes whenSWW_RPROXY_WAF_BACKENDis unset: refuses to start, runs with no WAF at all, or something else; and what it logs.Definition of done
The comment is posted. The issue is then assigned to sneak if a circuit breaker is recommended, as his decision; otherwise it is closed.
Model: opus-5-5
Answers from the head of
next(53d9bf6).SWW_RPROXY_WAF_BACKENDunset:rproxyrefuses to start.internal/config/rproxy.go:152-164: an unset or empty value records the erroris required: it is waf's URL, such as http://waf:8080(internal/config/rproxy.go:64-65). The_FILEform naming an empty file ends the same way.internal/config/rproxy.go:111-113: any invalid setting fails the whole read, and only the first is reported (internal/config/config.go:136-141). This setting is read fourth, so a bad listen address, upstream URL or trusted-proxy list is reported ahead of it, one per start.internal/sww/rproxy.go:31-36: one process line on stdout, JSON, levelERROR,msginvalid setting,errorSWW_RPROXY_WAF_BACKEND: is required: it is waf's URL, such as http://waf:8080, withtypeprocessandinstancethe host's name (internal/requestlog/requestlog.go:302-323).RunRProxyreturns 1,Mainreturns it (internal/sww/sww.go:92-93) and the process exits with it (cmd/sww/main.go:19). Nothing listens. There is no mode that runs without a WAF.internal/config/rproxy.go:120-135), so it does not need the setting.wafdown: there is no circuit breaker. Every request askswaf, and pays whatever the ask costs.One request (
internal/rproxy/rproxy.go:199-201,internal/rproxy/waf.go:92-131) asks under one deadline,SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, default 2 s, set atinternal/rproxy/waf.go:93. It covers resolving the name, connecting, sending the question and reading the whole answer. The transport that carries questions (internal/rproxy/rproxy.go:145-164) sets no dial timeout, no response-header timeout and no per-host connection limit, so that deadline is the only bound. Go'shttp.Transportwithout aDialContextdials with a zeronet.Dialer, whose default is no timeout (net/http/transport.go:1303-1320andnet/dial.go:127-131in Go 1.26).Per failure mode:
wafcontainer is stopped or restarting): the dial fails at once. The request waits a few milliseconds, then takesSWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION. No visible delay: withallowthe site runs unprotected at full speed, withdenyit answers503at once.wafon a host that is down or unreachable, or awafwhose accept queue is full): the dial waits the whole 2 s. Every request waits 2 s, each in parallel with the others (no per-host connection limit, so none queues behind another), then takes the disposition.wafreachable but overloaded, stuck, or kept busy by the request itself): the request waits up to 2 s for the complete answer. An answer within 2 s is used as normal. Past 2 s, the disposition.After each failure (
internal/rproxy/waf.go:57-87,internal/rproxy/request.go:414-471): one process warningwaf gave no answerper request,waf_backend_no_answer: trueon the request's log line, no report, and withallowthe request goes to the app, withdenythe visitor gets503.rproxykeeps nothing between requests (internal/rproxy/rproxy.go:9,SPEC.md:2585; the handler atinternal/rproxy/rproxy.go:122-140holds config, logs, two transports and the report URL, no failure state), so the next request asks again.GET /_sww/healthzis answered without askingwaf(internal/rproxy/rproxy.go:176-180), sorproxystays healthy throughout. Reports wait the same 2 s each, on their own goroutine (internal/rproxy/waf.go:229-285); no visitor waits for one.In short: a
wafthat is down costs each request nothing noticeable. Awafthat is unreachable or hung costs every request the full 2 s for as long as that lasts, plus one warning line per request.Circuit breaker: the simplest one, and why I recommend against it.
The simplest breaker is one
openUntiltime on the handler, under a mutex:SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITIONwithout asking, with the same warning and log field.waf; a usable answer closes it, a dial failure opens it again. No settings.context deadline exceeded, which is also what a slow answer gives.Its cost:
rproxy's first state between requests (SPEC promises none); up to the open time more of unprotected traffic (allow) or503s (deny) afterwafrecovers; a probe request per open period; changes toSPEC.md,docs/settings.md, the README, and tests for the closed, open and probe states.The catch:
wafslow (SPEC.md:2694; the 16,000-cookie request in #182). A breaker that opens on a timeout lets one visitor switch the WAF off for everyone for the open period (allow), or take the site down with one slow request per period (deny). That is a WAF bypass or a cheap denial of service, and it is what a breaker library gives by default.waf's answers well inside it is what the issue above built.Recommendation: no circuit breaker. If the hang case matters (a
wafon another host), the one change I would make is the connect timeout alone: a constant, say 500 ms, on the WAF transport. It cuts a hung dial from 2 s to 500 ms per request, keepsrproxystateless, and nothing a visitor sends can trigger it. Not filed; say so and it becomes its own issue.Not checked: live behaviour against a stopped or unreachable
waf. The waits above come from the code and Go's documented defaults, not from a measurement. Confidence: high on the start-up and per-request paths; medium on "a few milliseconds" for a refused connection, which depends on the network.Closing, as the definition of done says when no breaker is recommended.
Model: fable-5-1
A circuit breaker in this instance shouldn't open when a single request fails. It should require multiple failed requests over maybe five seconds. And then it should open until a request succeeds. And then it should or rather it should stay open until a request succeeds. At which point a single request succeeding should cause it to close again. When I say succeeding, I don't mean obviously approving the page from the decider, I mean the decider giving any answer uh whatsoever in a timely fashion.
Reading of sneak's comment above, for him to confirm before any code is written:
rproxycounts asks towafthat get no answer in time: refused, unreachable, or no complete answer within the backend timeout. Any answer in time counts as success, whatever it decides.SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITIONat once, without askingwaf. One request a second still asks; the first answer in time closes the breaker.The catch: a visitor can make
wafslow on purpose (the 16,000-cookie request in #182). Five slow requests in five seconds would open the breaker. Withallow, that turns the WAF off for every visitor until an answer comes back in time; withdeny, it answers503to everyone. Recommendation: count only failures to connect (refused, unreachable, connect timed out) toward opening, and add the 500 ms connect timeout so a hung host shows up as one. Slow answers then cost only the request that caused them.Held as
needs-decisionuntil he confirms.Model: opus-5-5
If a visitor can perform a denial of service attack on the server, that's a bug in the server. We need to make sure that any requests that a visitor sends can be decided within the timeout. If they cannot, that is a bug in the decider.
Ruling recorded: slow answers count as failures; a request
wafcannot decide in time is a bug inwaf. The breaker is filed for a worker as #392, with the constants (5 failures in 5 s, one ask a second while open) stated there as reversible choices. Closing this question.Model: opus-5-5