Gate a backend only on a probe that has answered for it #15

Merged
benvin merged 5 commits from benvin/health-failure-window into main 2026-09-06 10:28:50 +10:00
4 changed files with 407 additions and 113 deletions
+48 -21
View File
@@ -228,42 +228,69 @@ not answering.
or `unknown` counts as a failure. A body that is not in that shape is judged on
its status code alone, so pointing `health_probe_path` at some other endpoint
still works.
- **A refused probe is not a sick backend.** Only evidence about the *backend*
takes it out of the pool: a transport failure (connection refused, DNS, TLS,
- **A refused probe is not a sick backend.** Probe replies split in two. Evidence
about the *backend* is a transport failure (connection refused, DNS, TLS,
timeout), a `5xx` — `503` included, since trapperkeeper answers `503` exactly
when its services are not nominal — or a `429`, which is the backend reporting
its own capacity rather than judging the request, so an overloaded backend gets
backed off instead of kept at full traffic. A reply that refuses the *probe
request* is evidence about the probe instead: the other `4xx` are the backend
request* is evidence about the probe: the other `4xx` are the backend
answering that our request is the problem (`404`/`410` the path is not there,
`405` it does not take a `GET`, `401`/`403` we are not allowed to ask), and
`501` says it does not implement the
endpoint. A backend answering that way is **left in service** — still queried,
still contributing records — and reported as `probe_unsupported` rather than
`healthy`, so an operator can tell "verified healthy" from "not actually being
checked". A probe path that is wrong for one backend alone can therefore never
strand a working backend. The misconfiguration is logged once per transition,
naming the backend, the probe path and the status.
`501` says it does not implement the endpoint.
- **A backend is only gated on a probe that has worked for it.** Each backend
carries one latch: has its probe endpoint ever *answered* — replied with
something readable as healthy or unhealthy — since `pdbmux` started? A
rejection refused the request and a transport failure never reached the
endpoint, so neither one sets it; a `200`, a `503` or a degraded body does.
The latch decides which rule applies, and it never clears, so no repeating
pattern of failures can argue a backend back into service.
- **Never answered** — there is no health signal for this backend, so nothing
gates on one. It is **left in service** — still queried, still contributing
records — permanently, reported as `probe_unsupported` rather than
`healthy` so an operator can tell "verified healthy" from "not actually
being checked". Real failures do not take it out either: no conclusion about
a backend can be drawn from a probe that cannot run. This is the
misconfigured-path case, and it degrades that backend to the behaviour from
before health checks existed, which is the right floor. The
misconfiguration is logged once, naming the backend, the probe path and the
status. A backend that has been unreachable since `pdbmux` started has not
answered either, so it is not gated until it answers once — `reachable` on
`/healthz` is what reports it in the meantime.
- **Answered at least once** — the path works, so the probe is trusted and the
ordinary thresholds below apply. A later run of *rejections* counts as
failure, not `probe_unsupported`: a path that answered before and refuses
now has moved or changed its authorization, which is logged loudly when the
run starts.
- **Thresholds** — a healthy backend leaves the pool after
`health_probe_failures` (default 3) **consecutive** unsuccessful probes; a down
one comes back after `health_probe_successes` (default 2) consecutive
successes, and the same thresholds gate `probe_unsupported` in and out. One
blip cannot flap a backend out, and one lucky reply cannot flap it back in.
The run counts every unsuccessful probe whatever its kind, so a backend that
fails every probe in mixed ways — a `503`, then a `404`, then a timeout —
still trips the threshold; only a success resets the run. The kinds decide
*which* state the run enters: a real failure anywhere in the run outranks a
refusal, so the backend goes `unhealthy` and is skipped, and only a run of
nothing but refusals enters `probe_unsupported` and stays in service. A down
backend keeps being probed, so recovery is automatic.
successes, and the same failure threshold debounces the `probe_unsupported`
warning. One blip cannot flap a backend out, and one lucky reply cannot flap
it back in. The run counts every unsuccessful probe whatever its kind, so a
backend that fails every probe in mixed ways — a `503`, then a `404`, then a
timeout — still trips the threshold; only a success resets the run. Probes
from before the latch was set do not count toward it. A down backend keeps
being probed, so recovery is automatic.
- **Accepted trade-off: a probe path that is removed.** If a backend's probe
path works and later goes away — an upgrade, a proxy change — the latch is
already set, so the refusals count as failures and that backend is excluded
even though it is serving queries fine. Global fail-open still covers the case
where this happens to every backend, `/healthz` shows the state, and the log
line names the probe path: fix `health_probe_path`, or set
`health_probe_enabled: false`. There is no machinery to detect this
automatically — any rule that readmits a backend on "no real failure lately"
flaps a genuinely dead backend into service on a periodic failure pattern.
- **Fails open** — if the prober has marked **every** backend down, `pdbmux`
queries them all anyway. A wrong `health_probe_path`, a broken prober or a
partition that only the prober sees can therefore never black-hole traffic;
the worst case is today's behaviour.
- **Serves immediately** — the listener never waits for a first probe round, and
a backend nobody has probed yet counts as healthy, so a restart drops nothing.
- **Quiet** — only *transitions* (up→down, down→up, in and out of
`probe_unsupported`) are logged, never individual probes.
- **Quiet** — only *transitions* are logged, never individual probes: up→down,
down→up, a probe that has never answered reaching its failure threshold, the
first answer after that, and the start of a rejection run on a probe that used
to answer.
- **Partial responses stay partial.** Health state changes which backends are
asked, never what a merged answer means: a response built from a subset is
still served, as before. Every merged response carries `X-Backends:
+71 -43
View File
@@ -39,12 +39,17 @@ const (
)
type probeState struct {
healthy bool
unsupported bool
probed bool
// runFailed records whether the current run of not-OK probes contains at
// least one real failure, which decides which non-healthy state it enters.
runFailed bool
healthy bool
probed bool
// usable latches the first time the probe endpoint answers with a verdict on
// this backend. It never clears: whether the probe works for a backend is a
// property of the deployment, not something to re-decide every round.
usable bool
// rejecting tracks whether the last probe refused the request, so a rejection
// run is logged when it starts rather than on every probe.
rejecting bool
// warned records that the unusable-probe misconfiguration has been logged.
warned bool
failures int
successes int
lastProbe time.Time
@@ -87,6 +92,20 @@ func (e *probeRejectedError) Error() string {
return fmt.Sprintf("HTTP %d: probe path %q is not usable on this backend", e.status, e.path)
}
// probeAnsweredError marks a failure the probe endpoint itself reported: the
// request reached it and came back with a verdict on the backend's services.
type probeAnsweredError struct{ msg string }
func (e *probeAnsweredError) Error() string { return e.msg }
// probeAnswered reports whether a probe reply is a verdict we can read as
// healthy or unhealthy, which is what makes the probe usable for a backend. A
// rejection refused the request and a transport failure never reached the
// endpoint, so neither one shows that the configured path works.
func probeAnswered(err error) bool {
return err == nil || errors.As(err, new(*probeAnsweredError))
}
// probeRejects reports whether a status code refuses the probe request rather
// than reporting the service unhealthy. A 4xx is the backend answering that our
// request is the problem, and 501 says it does not implement the endpoint.
@@ -220,7 +239,7 @@ func (p *prober) snapshot() map[string]backendHealth {
for name, st := range p.states {
out[name] = backendHealth{
Healthy: st.healthy,
Unsupported: st.unsupported,
Unsupported: st.probed && !st.usable,
Probed: st.probed,
Failures: st.failures,
Successes: st.successes,
@@ -240,11 +259,14 @@ func (p *prober) probeOne(ctx context.Context, b Backend) {
p.record(b.Name, err)
}
// record applies one probe outcome, flipping state only once the matching
// threshold is reached so a single blip cannot flap a backend in either
// direction. One run of consecutive not-OK probes makes the down decision, no
// matter how the kinds are mixed; the kinds only pick which non-healthy state
// the run enters, and a real failure anywhere in the run outranks a rejection.
// record applies one probe outcome. Which rule applies is decided by a latch,
// not by anything that decays: until the probe endpoint has answered once, this
// backend has no health signal at all, so nothing may gate on one and it stays
// in service however its probes fail. Once the probe has answered, the path
// works and every unsuccessful probe counts toward the failure threshold —
// rejections included, since a path that answered before and refuses now has
// changed. The latch never clears, so a repeating pattern of failures cannot
// argue a backend back into service.
func (p *prober) record(name string, err error) {
p.mu.Lock()
st, ok := p.states[name]
@@ -256,49 +278,52 @@ func (p *prober) record(name string, err error) {
st.probed = true
st.lastProbe = p.now()
var transition string
var transitions []string
if !st.usable && probeAnswered(err) {
st.usable = true
// Only evidence from the working probe debounces the state.
st.failures, st.successes = 0, 0
if st.warned {
st.warned = false
transitions = append(transitions, fmt.Sprintf("info: backend %q now answers the health probe %q; its health is being checked again", name, p.path))
}
}
if outcome == outcomeOK {
st.lastErr = ""
st.failures, st.runFailed = 0, false
st.failures = 0
st.successes++
if st.successes >= p.successes {
switch {
case st.unsupported:
st.unsupported = false
transition = fmt.Sprintf("info: backend %q health probe %q is usable again", name, p.path)
case !st.healthy:
st.healthy = true
transition = fmt.Sprintf("info: backend %q is up after %d consecutive probe successes", name, st.successes)
}
if st.usable && !st.healthy && st.successes >= p.successes {
st.healthy = true
transitions = append(transitions, fmt.Sprintf("info: backend %q is up after %d consecutive probe successes", name, st.successes))
}
} else {
st.lastErr = err.Error()
st.successes = 0
st.failures++
st.runFailed = st.runFailed || outcome == outcomeFailure
if st.failures >= p.failures {
switch {
case st.runFailed:
// Some probe in this run was evidence about the backend itself, so the
// run is a failure run whatever else it also contains.
st.unsupported = false
if st.healthy {
st.healthy = false
transition = fmt.Sprintf("warning: backend %q is down after %d consecutive failed probes: %v", name, st.failures, err)
}
case !st.unsupported:
// Nothing but refusals of the probe request, which says nothing about the
// backend's health: it keeps serving queries, unverified.
st.unsupported, st.healthy = true, true
transition = fmt.Sprintf("warning: backend %q rejected the health probe %d times: %v; leaving it in service but unverified, fix health_probe_path or its authorization", name, st.failures, err)
if !st.usable {
// No probe has ever worked here, so this says nothing about the backend: it
// keeps serving queries, unverified.
if st.failures >= p.failures && !st.warned {
st.warned = true
transitions = append(transitions, fmt.Sprintf("warning: backend %q has never answered the health probe %q, last %v; leaving it in service but unverified, fix health_probe_path or its authorization", name, p.path, err))
}
} else {
if outcome == outcomeRejected && !st.rejecting {
transitions = append(transitions, fmt.Sprintf("warning: backend %q now rejects the health probe %q, which worked before: %v; the endpoint moved or its authorization changed, so the refusals count as failures", name, p.path, err))
}
if st.healthy && st.failures >= p.failures {
st.healthy = false
transitions = append(transitions, fmt.Sprintf("warning: backend %q is down after %d consecutive failed probes: %v", name, st.failures, err))
}
}
}
st.rejecting = outcome == outcomeRejected
p.mu.Unlock()
// Only transitions are logged: this loop runs for the life of the process.
if transition != "" {
p.log.Print(transition)
for _, t := range transitions {
p.log.Print(t)
}
}
@@ -321,9 +346,12 @@ func (p *prober) probe(ctx context.Context, b Backend) error {
return &probeRejectedError{status: resp.StatusCode, path: p.path}
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
return &probeAnsweredError{msg: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))}
}
return statusBodyHealthy(body)
if err := statusBodyHealthy(body); err != nil {
return &probeAnsweredError{msg: err.Error()}
}
return nil
}
// statusBodyHealthy rejects a 200 whose body reports a service that is starting,
+287 -48
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
@@ -76,20 +77,39 @@ func healthConfig(aURL, bURL string) Config {
return cfg
}
// answeredProbe is a failure the probe endpoint itself reported — a 503, say —
// so it proves the probe works and counts against the backend.
func answeredProbe() error {
return &probeAnsweredError{msg: "HTTP 503: service is not running"}
}
// deadProbe never reached the endpoint, so it says nothing about whether the
// probe works there.
func deadProbe() error { return errors.New("dial tcp: connection refused") }
func rejectedProbe(p *prober) error {
return &probeRejectedError{status: http.StatusNotFound, path: p.path}
}
// markUsable latches the probe as working without changing the backend's state.
func markUsable(t *testing.T, p *prober, name string) {
t.Helper()
p.record(name, nil)
if p.snapshot()[name].Unsupported {
t.Fatalf("backend %q still reports an unusable probe after a success", name)
}
}
func markDown(t *testing.T, p *prober, name string) {
t.Helper()
for i := 0; i < p.failures; i++ {
p.record(name, errors.New("probe failed"))
p.record(name, answeredProbe())
}
if p.healthy(name) {
t.Fatalf("backend %q still healthy after %d probe failures", name, p.failures)
}
}
func rejectedProbe(p *prober) error {
return &probeRejectedError{status: http.StatusNotFound, path: p.path}
}
func markUnsupported(t *testing.T, p *prober, name string) {
t.Helper()
for i := 0; i < p.failures; i++ {
@@ -109,19 +129,19 @@ func TestProber_FailureThresholdDebouncesABlip(t *testing.T) {
p := newTestProber(t, cfg)
for i := 1; i < 3; i++ {
p.record("a", errors.New("boom"))
p.record("a", answeredProbe())
if !p.healthy("a") {
t.Fatalf("backend went down after %d of 3 failures", i)
}
}
// A success inside the window resets the run, so the blip never flaps it out.
// A success resets the run, so the blip never flaps it out.
p.record("a", nil)
p.record("a", errors.New("boom"))
p.record("a", errors.New("boom"))
p.record("a", answeredProbe())
p.record("a", answeredProbe())
if !p.healthy("a") {
t.Fatal("a failure run interrupted by a success should not mark the backend down")
}
p.record("a", errors.New("boom"))
p.record("a", answeredProbe())
if p.healthy("a") {
t.Fatal("backend should be down after 3 consecutive failures")
}
@@ -138,7 +158,7 @@ func TestProber_SuccessThresholdDebouncesRecovery(t *testing.T) {
t.Fatal("one success should not bring a down backend back")
}
// A failure in between restarts the recovery run.
p.record("a", errors.New("boom"))
p.record("a", answeredProbe())
p.record("a", nil)
if p.healthy("a") {
t.Fatal("recovery run should restart after an interleaved failure")
@@ -574,6 +594,9 @@ func TestProber_ServerErrorsAndTransportFailuresStillMarkDown(t *testing.T) {
if classifyProbe(err) != outcomeFailure {
t.Fatalf("a refused connection classified as %v, want a real failure (err %v)", classifyProbe(err), err)
}
// A transport failure never reached the probe endpoint, so it is only evidence
// once the endpoint has answered before.
markUsable(t, p, "a")
for i := 0; i < p.failures; i++ {
p.record("a", err)
}
@@ -582,31 +605,83 @@ func TestProber_ServerErrorsAndTransportFailuresStillMarkDown(t *testing.T) {
}
}
// A real failure landing in a rejected run outranks the rejections: the run is
// one run of unsuccessful probes, and its worst evidence decides the state.
func TestProber_RealFailureInARejectedRunMarksDown(t *testing.T) {
// Only a reply from the probe endpoint shows that the probe works there: a
// transport failure never got one, and a rejection refused the request.
func TestProbeAnswered_OnlyRepliesFromTheEndpointCount(t *testing.T) {
sb := newStatusBackend(t)
p := newTestProber(t, healthConfig(sb.srv.URL, "http://b.invalid"))
probe := func() error { return p.probe(t.Context(), Backend{Name: "a", URL: sb.srv.URL}) }
sb.reply(http.StatusOK, runningStatusBody)
if !probeAnswered(probe()) {
t.Error("a running status was not counted as an answer")
}
sb.reply(http.StatusServiceUnavailable, degradedStatusBody("error"))
if !probeAnswered(probe()) {
t.Error("a 503 from the probe endpoint was not counted as an answer")
}
sb.reply(http.StatusOK, degradedStatusBody("starting"))
if !probeAnswered(probe()) {
t.Error("a degraded body on a 200 was not counted as an answer")
}
sb.reply(http.StatusNotFound, "nope")
if probeAnswered(probe()) {
t.Error("a rejected probe was counted as an answer")
}
if probeAnswered(deadProbe()) {
t.Error("a transport failure was counted as an answer")
}
}
// Once the probe endpoint answers, its verdict gates the backend even if every
// earlier probe was refused: the path works after all.
func TestProber_AnsweredFailureAfterRejectionsMarksDown(t *testing.T) {
p := newTestProber(t, healthConfig("http://a.invalid", "http://b.invalid"))
markUnsupported(t, p, "a")
for i := 0; i < 20; i++ {
p.record("a", rejectedProbe(p))
}
p.record("a", errors.New("boom"))
for i := 0; i < p.failures; i++ {
p.record("a", answeredProbe())
}
if p.healthy("a") {
t.Fatal("a real failure inside a rejected run left the backend in service")
t.Fatal("the backend stayed in service after its probe endpoint answered with failures")
}
if got := p.snapshot()["a"].stateName(); got != stateUnhealthy {
t.Errorf("state = %q, want %q", got, stateUnhealthy)
}
}
// The rejections before the endpoint answered are not evidence about the
// backend, so they do not count toward the threshold that takes it out.
func TestProber_RejectionsBeforeTheLatchDoNotCountAsFailures(t *testing.T) {
cfg := healthConfig("http://a.invalid", "http://b.invalid")
cfg.HealthProbeFailures = 3
p := newTestProber(t, cfg)
for i := 0; i < 20; i++ {
p.record("a", rejectedProbe(p))
}
for i := 1; i < cfg.HealthProbeFailures; i++ {
p.record("a", answeredProbe())
if !p.healthy("a") {
t.Fatalf("backend went down after %d of %d failures from a working probe", i, cfg.HealthProbeFailures)
}
}
p.record("a", answeredProbe())
if p.healthy("a") {
t.Fatal("backend should be down after a full run of failures from a working probe")
}
}
// The regression this guards: a backend failing every probe but flipping the
// kind of failure — a fronting proxy alternating 404 and 503, say — never
// answers a probe successfully, so it must go down at the threshold like any
// other permanently failing backend.
func TestProber_AlternatingOutcomeKindsStillMarkDown(t *testing.T) {
rejected := func(p *prober) error { return rejectedProbe(p) }
failed := func(*prober) error { return errors.New("boom") }
failed := func(*prober) error { return answeredProbe() }
ok := func(*prober) error { return nil }
cycles := map[string][]func(*prober) error{
@@ -667,12 +742,12 @@ func TestProber_SuccessResetsTheRunInEveryDirection(t *testing.T) {
case "rejected":
return rejectedProbe(p)
case "failure":
return errors.New("boom")
return answeredProbe()
default:
if i%2 == 0 {
return rejectedProbe(p)
}
return errors.New("boom")
return answeredProbe()
}
}
for i := 0; i < cfgFailures-1; i++ {
@@ -697,41 +772,167 @@ func TestProber_SuccessResetsTheRunInEveryDirection(t *testing.T) {
}
}
func TestProber_RecoversFromProbeUnsupported(t *testing.T) {
p := newTestProber(t, healthConfig("http://a.invalid", "http://b.invalid"))
markUnsupported(t, p, "a")
// The misconfigured-path case: nothing has ever answered the probe on this
// backend, so there is no health signal to gate on and it keeps serving queries
// however its probes fail — including real failures, since a probe that cannot
// run supports no conclusion about the backend behind it.
func TestProber_NeverAnsweredProbeStaysInServiceForever(t *testing.T) {
for _, failures := range []int{1, 2, 3, 5, 9} {
cfg := healthConfig("http://a.invalid", "http://b.invalid")
cfg.HealthProbeFailures = failures
p := newTestProber(t, cfg)
for i := 0; i < p.successes; i++ {
p.record("a", nil)
}
if !p.healthy("a") {
t.Fatal("backend left the pool while recovering")
}
if got := p.snapshot()["a"].stateName(); got != stateHealthy {
t.Fatalf("state = %q, want %q", got, stateHealthy)
for i := 0; i < 500; i++ {
if i%3 == 2 {
p.record("a", deadProbe())
} else {
p.record("a", rejectedProbe(p))
}
if !p.healthy("a") {
t.Fatalf("threshold %d: backend left the pool at probe %d on a probe that never worked", failures, i+1)
}
if got := p.snapshot()["a"].stateName(); got != stateProbeUnsupported {
t.Fatalf("threshold %d: state = %q at probe %d, want %q", failures, got, i+1, stateProbeUnsupported)
}
}
}
}
// Rejections cannot talk a down backend back into the pool while its failure run
// is unbroken — the failures are still the run's best evidence. A successful
// probe ends the run, and rejections after that mean the probe stopped working
// rather than the backend.
func TestProber_RejectedProbeReadmitsADownBackendOnlyAfterASuccess(t *testing.T) {
// Once the probe has answered, the latch is set for good: a later run of
// rejections means the endpoint moved or its authorization changed, so it
// counts as failure instead of putting the backend back into probe_unsupported.
func TestProber_RejectionRunAfterTheLatchIsAFailureRun(t *testing.T) {
p := newTestProber(t, healthConfig("http://a.invalid", "http://b.invalid"))
markDown(t, p, "a")
markUsable(t, p, "a")
for i := 0; i < p.failures*5; i++ {
for i := 0; i < 100; i++ {
p.record("a", rejectedProbe(p))
if p.healthy("a") {
t.Fatal("rejections re-admitted a backend whose failure run never ended")
}
}
if p.healthy("a") {
t.Fatal("a rejection run after the probe had worked left the backend in service")
}
if got := p.snapshot()["a"].stateName(); got != stateUnhealthy {
t.Fatalf("state = %q, want %q", got, stateUnhealthy)
}
}
p.record("a", nil)
markUnsupported(t, p, "a")
// With the latch set the ordinary thresholds apply to every kind of
// unsuccessful probe, in both directions.
func TestProber_LatchedBackendExcludesAndReadmits(t *testing.T) {
for _, fail := range []func() error{answeredProbe, deadProbe} {
p := newTestProber(t, healthConfig("http://a.invalid", "http://b.invalid"))
markUsable(t, p, "a")
for i := 1; i < p.failures; i++ {
p.record("a", fail())
if !p.healthy("a") {
t.Fatalf("backend left the pool after %d of %d failures", i, p.failures)
}
}
p.record("a", fail())
if p.healthy("a") {
t.Fatal("backend stayed in service after a full failure run")
}
if got := p.snapshot()["a"].stateName(); got != stateUnhealthy {
t.Fatalf("state = %q, want %q", got, stateUnhealthy)
}
for i := 1; i < p.successes; i++ {
p.record("a", nil)
if p.healthy("a") {
t.Fatalf("backend came back after %d of %d successes", i, p.successes)
}
}
p.record("a", nil)
if !p.healthy("a") {
t.Fatal("backend did not come back after a full success run")
}
if got := p.snapshot()["a"].stateName(); got != stateHealthy {
t.Fatalf("state = %q, want %q", got, stateHealthy)
}
}
}
// Every state the prober can hold recovers on nothing but successes.
func TestProber_RecoversFromEveryState(t *testing.T) {
setups := map[string]func(*testing.T, *prober){
stateUnprobed: func(*testing.T, *prober) {},
stateUnhealthy: func(t *testing.T, p *prober) { markDown(t, p, "a") },
stateProbeUnsupported: func(t *testing.T, p *prober) { markUnsupported(t, p, "a") },
}
for name, setup := range setups {
t.Run(name, func(t *testing.T) {
p := newTestProber(t, healthConfig("http://a.invalid", "http://b.invalid"))
setup(t, p)
for i := 0; i < p.successes; i++ {
p.record("a", nil)
}
if !p.healthy("a") {
t.Fatal("backend did not recover on consecutive successes")
}
if got := p.snapshot()["a"].stateName(); got != stateHealthy {
t.Fatalf("state = %q, want %q", got, stateHealthy)
}
})
}
}
// The regression this guards: the decaying rule it replaced readmitted a dead
// backend on a repeating pattern of one failure followed by a run of
// rejections, once per cycle, forever. Once the probe has answered, no pattern
// of unsuccessful probes may put a backend back into service — only a success
// may, and these backends never give one.
func TestProber_MixedFailureCyclesNeverFlapBackIntoService(t *testing.T) {
rejected := func(p *prober) error { return rejectedProbe(p) }
failed := func(*prober) error { return answeredProbe() }
dead := func(*prober) error { return deadProbe() }
cycles := map[string][]func(*prober) error{
"rejected/failure": {rejected, failed},
"failure/rejected": {failed, rejected},
"failure-heavy 3-cycle": {rejected, failed, failed},
"paired 4-cycle": {rejected, rejected, failed, failed},
"transport/rejected": {dead, rejected},
"all rejections": {rejected},
}
// The reviewer's exact counterexample, at every run length past any window the
// old rule used, in both failure flavours.
for n := 1; n <= 10; n++ {
run := make([]func(*prober) error, n)
for i := range run {
run[i] = rejected
}
cycles[fmt.Sprintf("failure then %d rejections", n)] = append([]func(*prober) error{failed}, run...)
cycles[fmt.Sprintf("transport failure then %d rejections", n)] = append([]func(*prober) error{dead}, run...)
}
for name, cycle := range cycles {
for failures := 1; failures <= 9; failures++ {
t.Run(fmt.Sprintf("%s/threshold %d", name, failures), func(t *testing.T) {
cfg := healthConfig("http://a.invalid", "http://b.invalid")
cfg.HealthProbeFailures = failures
p := newTestProber(t, cfg)
markUsable(t, p, "a")
down := false
for i := 0; i < 600; i++ {
p.record("a", cycle[i%len(cycle)](p))
switch {
case !p.healthy("a"):
down = true
case down:
t.Fatalf("backend flapped back into service at probe %d", i+1)
}
}
if !down {
t.Fatal("a backend that failed 600 probes never left the pool")
}
if got := p.snapshot()["a"].stateName(); got != stateUnhealthy {
t.Errorf("state = %q, want %q", got, stateUnhealthy)
}
})
}
}
}
func TestProber_LogsProbeRejectionOncePerTransition(t *testing.T) {
@@ -742,10 +943,17 @@ func TestProber_LogsProbeRejectionOncePerTransition(t *testing.T) {
}
rejected := &probeRejectedError{status: http.StatusNotFound, path: p.path}
// The warning is debounced by the failure threshold like every other verdict.
for i := 0; i < p.failures-1; i++ {
p.record("a", rejected)
}
if buf.Len() != 0 {
t.Fatalf("a sub-threshold rejection run was already logged:\n%s", buf.String())
}
for i := 0; i < 50; i++ {
p.record("a", rejected)
}
if n := strings.Count(buf.String(), "rejected the health probe"); n != 1 {
if n := strings.Count(buf.String(), "never answered the health probe"); n != 1 {
t.Fatalf("logged the misconfiguration %d times, want once:\n%s", n, buf.String())
}
if !strings.Contains(buf.String(), `backend "a"`) || !strings.Contains(buf.String(), defaultHealthProbePath) {
@@ -755,18 +963,49 @@ func TestProber_LogsProbeRejectionOncePerTransition(t *testing.T) {
t.Errorf("log line does not name the status: %s", buf.String())
}
// Recovery logs its own single line, and a second rejected run logs again.
// The probe starting to answer logs its own single line.
for i := 0; i < p.successes+5; i++ {
p.record("a", nil)
}
if n := strings.Count(buf.String(), "usable again"); n != 1 {
t.Fatalf("logged recovery %d times, want once:\n%s", n, buf.String())
if n := strings.Count(buf.String(), "now answers the health probe"); n != 1 {
t.Fatalf("logged the probe becoming usable %d times, want once:\n%s", n, buf.String())
}
// A rejection run once the probe has worked is a different event, logged loudly
// when it starts and never again while it lasts.
buf.Reset()
for i := 0; i < 50; i++ {
p.record("a", rejected)
}
if n := strings.Count(buf.String(), "rejected the health probe"); n != 2 {
t.Fatalf("second rejected run logged %d times in total, want 2:\n%s", n, buf.String())
if n := strings.Count(buf.String(), "now rejects the health probe"); n != 1 {
t.Fatalf("logged the rejection run %d times, want once:\n%s", n, buf.String())
}
if n := strings.Count(buf.String(), "never answered the health probe"); n != 0 {
t.Errorf("a rejection run after the probe worked was reported as an unusable probe:\n%s", buf.String())
}
if n := strings.Count(buf.String(), "is down after"); n != 1 {
t.Errorf("the backend going down was logged %d times, want once:\n%s", n, buf.String())
}
}
func TestProber_LogsEachDownAndUpTransitionOnce(t *testing.T) {
var buf strings.Builder
p := newProber(healthConfig("http://a.invalid", "http://b.invalid"), log.New(&buf, "", 0))
if p == nil {
t.Fatal("newProber returned nil for an enabled config")
}
for i := 0; i < p.failures+20; i++ {
p.record("a", answeredProbe())
}
for i := 0; i < p.successes+20; i++ {
p.record("a", nil)
}
if n := strings.Count(buf.String(), "is down after"); n != 1 {
t.Errorf("logged the backend going down %d times, want once:\n%s", n, buf.String())
}
if n := strings.Count(buf.String(), "is up after"); n != 1 {
t.Errorf("logged the backend coming back %d times, want once:\n%s", n, buf.String())
}
}
+1 -1
View File
@@ -682,7 +682,7 @@ type backendReport struct {
// every configured backend regardless of probe state: "ok" or the error text.
Reachable string `json:"reachable"`
// State is the background prober's verdict. probe_unsupported means the probe
// path is unusable on this backend, so its health is unknown — read Reachable
// has never answered on this backend, so its health is unknown — read Reachable
// to find out whether it is up.
State string `json:"state"` // healthy | unhealthy | probe_unsupported | unprobed | unmonitored
Failures int `json:"consecutive_failures"`