rproxy: circuit breaker for asks to waf (closes #392)
check / check (push) Successful in 5m31s
check / check (push) Successful in 5m31s
Five asks that get no answer in time within five seconds open the breaker. While open, a request takes SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION without asking waf, except one ask a second; the first answer closes it. Slow answers count as failures. 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:
1 parent
8efd6339a6
commit
969383894c
10 files changed
+391
-18
No files matched your search
@@ -0,0 +1,83 @@
|
||||
package rproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What opens the circuit breaker, and how often a request asks waf while it is
|
||||
// open.
|
||||
const (
|
||||
breakerFailures = 5
|
||||
breakerWindow = 5 * time.Second
|
||||
breakerAskInterval = time.Second
|
||||
)
|
||||
|
||||
// errBreakerOpen is why a request did not ask waf.
|
||||
var errBreakerOpen = errors.New(
|
||||
"waf is not asked while the circuit breaker is open",
|
||||
)
|
||||
|
||||
// breaker is the circuit breaker for asks to waf. It opens when
|
||||
// breakerFailures asks fail within breakerWindow. An ask fails when it gets no
|
||||
// answer in time. While it is open, at most one request each breakerAskInterval
|
||||
// asks waf. The first answer closes it, whatever it decides.
|
||||
type breaker struct {
|
||||
mu sync.Mutex
|
||||
// failures holds the times of the latest failed asks, oldest first, at most
|
||||
// breakerFailures of them.
|
||||
failures []time.Time
|
||||
open bool
|
||||
// lastAsk is when a request last asked waf while the breaker was open.
|
||||
lastAsk time.Time
|
||||
}
|
||||
|
||||
// mayAsk reports whether a request may ask waf at now. While the breaker is
|
||||
// open, it says yes once each breakerAskInterval.
|
||||
func (b *breaker) mayAsk(now time.Time) bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if !b.open {
|
||||
return true
|
||||
}
|
||||
|
||||
if now.Sub(b.lastAsk) < breakerAskInterval {
|
||||
return false
|
||||
}
|
||||
|
||||
b.lastAsk = now
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// noteAnswer notes that an ask got an answer in time. It closes the breaker,
|
||||
// and forgets the failures before it.
|
||||
func (b *breaker) noteAnswer() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.open {
|
||||
b.open = false
|
||||
b.failures = nil
|
||||
}
|
||||
}
|
||||
|
||||
// noteFailure notes that an ask that ended at now got no answer in time, and
|
||||
// opens the breaker if breakerFailures asks have failed within breakerWindow.
|
||||
func (b *breaker) noteFailure(now time.Time) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.failures = append(b.failures, now)
|
||||
if len(b.failures) > breakerFailures {
|
||||
b.failures = b.failures[1:]
|
||||
}
|
||||
|
||||
if !b.open && len(b.failures) == breakerFailures &&
|
||||
now.Sub(b.failures[0]) <= breakerWindow {
|
||||
b.open = true
|
||||
b.lastAsk = now
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package rproxy_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
)
|
||||
|
||||
// The breaker tests give the breaker each time themselves, counted from
|
||||
// time.Now(), and never wait.
|
||||
|
||||
// fail notes count failed asks on b, one each step from at.
|
||||
func fail(b *rproxy.Breaker, at time.Time, count int, step time.Duration) {
|
||||
for i := range count {
|
||||
b.NoteFailure(at.Add(time.Duration(i) * step))
|
||||
}
|
||||
}
|
||||
|
||||
// openBreaker returns a breaker opened at now by five failed asks, one a
|
||||
// second.
|
||||
func openBreaker(now time.Time) *rproxy.Breaker {
|
||||
b := &rproxy.Breaker{}
|
||||
fail(b, now.Add(-4*time.Second), 5, time.Second)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func TestFourFailedAsksWithinFiveSecondsLeaveTheBreakerClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
b := &rproxy.Breaker{}
|
||||
fail(b, now, 4, time.Second)
|
||||
|
||||
if !b.MayAsk(now.Add(3 * time.Second)) {
|
||||
t.Error("a request may not ask, want it to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFiveFailedAsksWithinFiveSecondsOpenTheBreaker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
b := openBreaker(now)
|
||||
|
||||
if b.MayAsk(now) {
|
||||
t.Error("a request may ask, want it not to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFiveFailedAsksOverMoreThanFiveSecondsLeaveTheBreakerClosed(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
b := &rproxy.Breaker{}
|
||||
fail(b, now, 5, 1300*time.Millisecond)
|
||||
|
||||
if !b.MayAsk(now.Add(6 * time.Second)) {
|
||||
t.Error("a request may not ask, want it to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerBeforeTheBreakerOpensDoesNotForgetFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
b := &rproxy.Breaker{}
|
||||
fail(b, now, 4, time.Millisecond)
|
||||
b.NoteAnswer()
|
||||
b.NoteFailure(now.Add(time.Second))
|
||||
|
||||
if b.MayAsk(now.Add(time.Second)) {
|
||||
t.Error("a request may ask, want it not to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOpenBreakerLetsOneRequestAskEachSecond(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Now()
|
||||
b := openBreaker(now)
|
||||
|
||||
for _, tc := range []struct {
|
||||
after time.Duration
|
||||
want bool
|
||||
}{
|
||||
{999 * time.Millisecond, false},
|
||||
{time.Second, true},
|
||||
{time.Second, false},
|
||||
{1500 * time.Millisecond, false},
|
||||
{2 * time.Second, true},
|
||||
} {
|
||||
got := b.MayAsk(now.Add(tc.after))
|
||||
if got != tc.want {
|
||||
t.Errorf("may ask %v after it opened: %v, want %v",
|
||||
tc.after, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedAskKeepsTheBreakerOpen(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opened := time.Now()
|
||||
b := openBreaker(opened)
|
||||
now := opened.Add(time.Second)
|
||||
b.MayAsk(now)
|
||||
b.NoteFailure(now)
|
||||
|
||||
if b.MayAsk(now.Add(999 * time.Millisecond)) {
|
||||
t.Error("a request may ask, want it not to")
|
||||
}
|
||||
|
||||
if !b.MayAsk(now.Add(time.Second)) {
|
||||
t.Error("a request may not ask a second later, want it to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneAnswerClosesTheBreaker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opened := time.Now()
|
||||
b := openBreaker(opened)
|
||||
now := opened.Add(time.Second)
|
||||
b.MayAsk(now)
|
||||
b.NoteAnswer()
|
||||
|
||||
if !b.MayAsk(now) {
|
||||
t.Error("a request may not ask, want it to")
|
||||
}
|
||||
|
||||
// The failures before the answer no longer count, so one more does not open
|
||||
// the breaker again.
|
||||
b.NoteFailure(now)
|
||||
|
||||
if !b.MayAsk(now) {
|
||||
t.Error("a request may not ask after one failure, want it to")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package rproxy
|
||||
|
||||
import "time"
|
||||
|
||||
// Breaker is the circuit breaker, for tests that give it the time.
|
||||
type Breaker struct {
|
||||
b breaker
|
||||
}
|
||||
|
||||
// MayAsk reports whether a request may ask waf at now.
|
||||
func (b *Breaker) MayAsk(now time.Time) bool {
|
||||
return b.b.mayAsk(now)
|
||||
}
|
||||
|
||||
// NoteAnswer notes that an ask got an answer in time.
|
||||
func (b *Breaker) NoteAnswer() {
|
||||
b.b.noteAnswer()
|
||||
}
|
||||
|
||||
// NoteFailure notes that an ask that ended at now got no answer in time.
|
||||
func (b *Breaker) NoteFailure(now time.Time) {
|
||||
b.b.noteFailure(now)
|
||||
}
|
||||
@@ -4,10 +4,11 @@
|
||||
// waf goes to the visitor as waf gave it.
|
||||
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION decides only when waf cannot
|
||||
// be connected to, does not answer at all, or does not answer within
|
||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||
// method it does not pass on, or one it cannot put to waf as the visitor
|
||||
// sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in
|
||||
// SPEC.md says what it does.
|
||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, or while the circuit breaker for asks to
|
||||
// waf is open. rproxy itself refuses a request with a method it does not pass
|
||||
// on, or one it cannot put to waf as the visitor sent it. It keeps nothing
|
||||
// between requests but the circuit breaker, and writes nothing to disk.
|
||||
// "rproxy" in SPEC.md says what it does.
|
||||
package rproxy
|
||||
|
||||
import (
|
||||
@@ -132,6 +133,8 @@ type handler struct {
|
||||
wafTransport http.RoundTripper
|
||||
// reportURL is where the reports go.
|
||||
reportURL string
|
||||
// breaker is the circuit breaker for asks to waf.
|
||||
breaker breaker
|
||||
// reports waits for the reports being sent. reportsMu guards starting one
|
||||
// against reportsStopped, which is set once nothing is to wait for another.
|
||||
reports sync.WaitGroup
|
||||
@@ -196,7 +199,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
answer, err := rq.ask(r.Context())
|
||||
answer, err := rq.askUnlessBreakerOpen(r.Context())
|
||||
if err != nil {
|
||||
rq.handleNoUsableAnswer(r.Context(), err)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -1395,6 +1396,62 @@ func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
||||
standIn.wantNoReport(t)
|
||||
}
|
||||
|
||||
func TestFiveFailedAsksKeepTheNextRequestFromAskingWAF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, failure := range map[string]http.HandlerFunc{
|
||||
"no answer at all": func(http.ResponseWriter, *http.Request) {
|
||||
panic(http.ErrAbortHandler)
|
||||
},
|
||||
"a slow answer": func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var answering atomic.Bool
|
||||
|
||||
standIn := startWAF(
|
||||
t,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if answering.Load() {
|
||||
allow(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
failure(w, r)
|
||||
},
|
||||
)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(
|
||||
t,
|
||||
standIn.URL,
|
||||
app.URL,
|
||||
map[string]string{
|
||||
wafBackendTimeout: "200",
|
||||
},
|
||||
)
|
||||
|
||||
for range 5 {
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
standIn.question(t)
|
||||
}
|
||||
|
||||
answering.Store(true)
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
out.waitFor(t, "the circuit breaker is open")
|
||||
|
||||
select {
|
||||
case <-standIn.questions:
|
||||
t.Error("waf was asked, want it not to be")
|
||||
case <-time.After(10 * pollInterval):
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+28
-1
@@ -10,6 +10,7 @@ import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
@@ -67,7 +68,8 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||
rq.answer(http.StatusBadGateway)
|
||||
default:
|
||||
// waf cannot be connected to, does not answer at all, or does not answer
|
||||
// within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. A request that keeps waf
|
||||
// within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS; or the circuit breaker
|
||||
// kept the request from asking. 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
|
||||
// here. deny closes this, at the cost of answering 503 whenever waf is
|
||||
@@ -86,6 +88,31 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// askUnlessBreakerOpen asks waf, unless the circuit breaker is open and it is
|
||||
// not yet time for a request to ask. It then tells the breaker whether waf
|
||||
// answered in time. An answer rproxy cannot pass on still came in time. A
|
||||
// visitor that goes away tells the breaker nothing. ctx is the request's own
|
||||
// context.
|
||||
func (rq *request) askUnlessBreakerOpen(
|
||||
ctx context.Context,
|
||||
) (*wafAnswer, error) {
|
||||
b := &rq.h.breaker
|
||||
if !b.mayAsk(time.Now()) {
|
||||
return nil, errBreakerOpen
|
||||
}
|
||||
|
||||
answer, err := rq.ask(ctx)
|
||||
|
||||
switch {
|
||||
case err == nil, errors.Is(err, errCannotPassOn):
|
||||
b.noteAnswer()
|
||||
case ctx.Err() == nil:
|
||||
b.noteFailure(time.Now())
|
||||
}
|
||||
|
||||
return answer, err
|
||||
}
|
||||
|
||||
// ask sends waf the question, and returns its answer, which must have come
|
||||
// whole within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It ends when ctx does, as
|
||||
// when the visitor goes away.
|
||||
|
||||
Reference in new issue
Block a user