Third section of #141. start no longer fetches the identity provider's discovery document before it serves. Setup returns a Config whose two discovery values are empty, and Discover runs beside the server from before the listener opens: it fetches the document, publishes the verifier and then the sign-in endpoints, logs "identity provider discovery succeeded; sign-in is available", and returns. Until it has, /login and /callback answer a styled 503 "Sign-in unavailable" and /register "Registration unavailable", each with Retry-After: 5 and a "Try again" action, and a sign-in request wakes the loop's wait. A document whose issuer is not the configured one, or that names no end-session endpoint (or one that does not parse as an absolute URL), publishes nothing that sign-in reads and is retried at the cap. The two values sit behind two atomic.Pointers in auth.Config. The sign-in value holds a pointer to the verifier value it was published with, and is stored only after the verifier, from the same document, so a request never pairs endpoints from one document with a verifier from another. Re-derivation of roles reads the verifier alone, so it runs while sign-in still waits for an end-session endpoint; POST /logout before discovery ends the console's session, lands on /, and revokes nothing. A Config built by hand, as most tests build theirs, reads its exported fields as it did. internal/retryloop is the loop both discovery and, in a later section, the Temporal connection run: waits from 0.5 seconds doubling to a 15-second cap, plus up to half again of jitter; a wake ends a wait no sooner than a second after the last attempt began; a failure marked AtCap makes only the next wait's base the cap, and a wake does not end that wait. Its log decay writes the first failure, a changed error at most once per 30 seconds, otherwise one line per 5 minutes, Warn until the escalation time and Error from it, with the line at the escalation time written at once; every line's attempts is the attempts so far. The htmx 5xx toast reads "Server error. Try again later." Corrections to the change text, made while building: the wait after an AtCap failure is the cap plus the jitter (the design's bound of about 23 seconds after an outage already counted the jitter), and every log line's attempts is the number of attempts so far (one meaning on every line, matching elapsed), where the design said the cap alone and the attempts since the last line. internal/server/testdata/routes-not-ready.golden is new: it differs from routes.golden in the four sign-in rows only. routes.golden and route_pages.golden do not change. Full suite on reset databases: 4422 pass, 15 skip, none failed. The console booted on the test stack and logged the discovery success. go test -race -p 1 over internal/retryloop, internal/auth and internal/server after reset-test-db.sh: every package passes, no data race. make lint passes. Breaks, each caught: - LoginHandler's gate after RenewToken - the gate without the wake - the gate answers through http.Error - the gate answers 500 in place of 503 - the gate without Retry-After - /register without the gate - the sign-in value published from a document without an end-session endpoint - the sign-in value stored outside the issuer check - the verifier published only together with the sign-in value - the verifier published from a document with another issuer - an unusable document returned as a plain error - the attempt on http.DefaultClient - LogoutHandler before publication answers 500 - LogoutHandler before publication redirects without destroying the session - LogoutHandler with the verifier and no end-session endpoint revokes nothing - refreshIdentity ends the session when no verifier is published - Run without the cap - Run without the jitter - a wake that ends an AtCap wait - the wait ignores ctx.Done() - the discovery holder is a plain field in place of an atomic.Pointer - a Failure's level ignored - the decay without its five-minute rule - the escalation never reached - the changed-text rule without its thirty-second floor - a wake that ignores the one-second gap - answer() without the Retry-After line - re-derivation reads the exported fields in place of the holder - a level Error line written at once whatever the last line - an AtCap failure ratchets the doubling to the cap - the discovery log lines without the issuer attribute - the failure lines with a fixed attempts attribute - an end-session endpoint that does not parse or is not absolute is accepted - Run attempts once on a context that has ended - a Failure returned by pointer drops its message and level
593 lines
18 KiB
Go
593 lines
18 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package retryloop
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"sync"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
)
|
|
|
|
// deadline bounds every wait a test makes on the loop's goroutine; a test
|
|
// that reaches it fails naming what it waited for.
|
|
const deadline = 5 * time.Second
|
|
|
|
// fakeClock is the loop's clock. In auto mode a timer fires at once and moves
|
|
// the clock by its duration, so a run is a sequence of recorded waits. In
|
|
// manual mode a timer is announced on timers and fires when the test says so.
|
|
type fakeClock struct {
|
|
mu sync.Mutex
|
|
now time.Time
|
|
auto bool
|
|
waits []time.Duration
|
|
timers chan *fakeTimer
|
|
}
|
|
|
|
type fakeTimer struct {
|
|
clock *fakeClock
|
|
c chan time.Time
|
|
d time.Duration
|
|
}
|
|
|
|
func newClock(auto bool) *fakeClock {
|
|
return &fakeClock{
|
|
now: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC),
|
|
auto: auto,
|
|
timers: make(chan *fakeTimer, 64),
|
|
}
|
|
}
|
|
|
|
func (f *fakeClock) Now() time.Time {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.now
|
|
}
|
|
|
|
func (f *fakeClock) advance(d time.Duration) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.now = f.now.Add(d)
|
|
}
|
|
|
|
func (f *fakeClock) NewTimer(d time.Duration) Timer {
|
|
t := &fakeTimer{clock: f, c: make(chan time.Time, 1), d: d}
|
|
f.mu.Lock()
|
|
f.waits = append(f.waits, d)
|
|
auto := f.auto
|
|
f.mu.Unlock()
|
|
if auto {
|
|
t.fire()
|
|
return t
|
|
}
|
|
f.timers <- t
|
|
return t
|
|
}
|
|
|
|
func (t *fakeTimer) fire() {
|
|
t.clock.advance(t.d)
|
|
t.c <- t.clock.Now()
|
|
}
|
|
|
|
func (t *fakeTimer) C() <-chan time.Time { return t.c }
|
|
func (t *fakeTimer) Stop() bool { return true }
|
|
|
|
// seeded returns two sources that yield the same numbers, so a test can
|
|
// compute the jitter the loop drew.
|
|
func seeded() (*rand.Rand, *rand.Rand) {
|
|
return rand.New(rand.NewPCG(7, 11)), rand.New(rand.NewPCG(7, 11))
|
|
}
|
|
|
|
// noJitter draws zero every time, so a wait is exactly its base.
|
|
type noJitter struct{}
|
|
|
|
func (noJitter) Float64() float64 { return 0 }
|
|
|
|
// recordingHandler keeps what the loop logged.
|
|
type recordingHandler struct {
|
|
mu sync.Mutex
|
|
records []slog.Record
|
|
}
|
|
|
|
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
|
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.records = append(h.records, r)
|
|
return nil
|
|
}
|
|
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
|
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
|
|
|
|
func (h *recordingHandler) lines() []logLine {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
out := make([]logLine, 0, len(h.records))
|
|
for _, r := range h.records {
|
|
line := logLine{level: r.Level, message: r.Message, attrs: map[string]slog.Value{}}
|
|
r.Attrs(func(a slog.Attr) bool {
|
|
line.attrs[a.Key] = a.Value
|
|
return true
|
|
})
|
|
out = append(out, line)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type logLine struct {
|
|
level slog.Level
|
|
message string
|
|
attrs map[string]slog.Value
|
|
}
|
|
|
|
func (l logLine) elapsed() time.Duration { return l.attrs["elapsed"].Duration() }
|
|
|
|
// started is a Run in its own goroutine.
|
|
type started struct {
|
|
result chan error
|
|
}
|
|
|
|
func start(ctx context.Context, p Policy, attempt func(context.Context) error) *started {
|
|
s := &started{result: make(chan error, 1)}
|
|
go func() { s.result <- Run(ctx, p, attempt) }()
|
|
return s
|
|
}
|
|
|
|
func (s *started) await(t *testing.T, what string) error {
|
|
t.Helper()
|
|
select {
|
|
case err := <-s.result:
|
|
return err
|
|
case <-time.After(deadline):
|
|
t.Fatalf("waited %v for %s", deadline, what)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// nextTimer waits for the loop to announce its next wait.
|
|
func nextTimer(t *testing.T, c *fakeClock, what string) *fakeTimer {
|
|
t.Helper()
|
|
select {
|
|
case timer := <-c.timers:
|
|
return timer
|
|
case <-time.After(deadline):
|
|
t.Fatalf("waited %v for %s", deadline, what)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// failing returns an attempt that fails with err each time and signals the
|
|
// clock reading at each start.
|
|
func failing(c *fakeClock, err error, began chan<- time.Time) func(context.Context) error {
|
|
return func(context.Context) error {
|
|
began <- c.Now()
|
|
return err
|
|
}
|
|
}
|
|
|
|
func receive[T any](t *testing.T, ch <-chan T, what string) T {
|
|
t.Helper()
|
|
select {
|
|
case v := <-ch:
|
|
return v
|
|
case <-time.After(deadline):
|
|
t.Fatalf("waited %v for %s", deadline, what)
|
|
var zero T
|
|
return zero
|
|
}
|
|
}
|
|
|
|
func TestRunWaitsFollowThePolicy(t *testing.T) {
|
|
clock := newClock(true)
|
|
drawn, expected := seeded()
|
|
attempts := 0
|
|
p := Policy{Clock: clock, Rand: drawn, Logger: slog.New(&recordingHandler{})}
|
|
err := Run(t.Context(), p, func(context.Context) error {
|
|
attempts++
|
|
if attempts <= 8 {
|
|
return errors.New("refused")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run = %v, want nil", err)
|
|
}
|
|
bases := []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second, 15 * time.Second, 15 * time.Second, 15 * time.Second}
|
|
if len(clock.waits) != len(bases) {
|
|
t.Fatalf("waits = %v, want %d of them", clock.waits, len(bases))
|
|
}
|
|
for i, base := range bases {
|
|
want := base + time.Duration(expected.Float64()*float64(base)/2)
|
|
if clock.waits[i] != want {
|
|
t.Errorf("wait %d = %v, want %v (base %v plus the seeded fraction of half of it)", i+1, clock.waits[i], want, base)
|
|
}
|
|
}
|
|
}
|
|
|
|
// wakeRow is one attempt that takes took, then a wake in the wait after it.
|
|
type wakeRow struct {
|
|
name string
|
|
took time.Duration
|
|
// gap is the time from the start of the first attempt to the start of the
|
|
// second.
|
|
gap time.Duration
|
|
}
|
|
|
|
func runWakeRow(t *testing.T, row wakeRow) {
|
|
clock := newClock(false)
|
|
wake := make(chan struct{}, 1)
|
|
began := make(chan time.Time, 4)
|
|
calls := 0
|
|
p := Policy{FirstWait: 15 * time.Second, Cap: 15 * time.Second, Clock: clock, Rand: noJitter{}, Wake: wake, Logger: slog.New(&recordingHandler{})}
|
|
run := start(t.Context(), p, func(context.Context) error {
|
|
began <- clock.Now()
|
|
calls++
|
|
if calls == 1 {
|
|
clock.advance(row.took)
|
|
return errors.New("refused")
|
|
}
|
|
return nil
|
|
})
|
|
first := receive(t, began, "the first attempt")
|
|
wait := nextTimer(t, clock, "the fifteen-second wait")
|
|
if wait.d != 15*time.Second {
|
|
t.Fatalf("first wait = %v, want 15s", wait.d)
|
|
}
|
|
wake <- struct{}{}
|
|
if remaining := row.gap - row.took; remaining > 0 {
|
|
short := nextTimer(t, clock, "the wait that a wake shortens to the one-second gap")
|
|
if short.d != remaining {
|
|
t.Fatalf("a wake %v into the attempt left a wait of %v, want %v", row.took, short.d, remaining)
|
|
}
|
|
short.fire()
|
|
}
|
|
second := receive(t, began, "the attempt a wake starts")
|
|
if got := second.Sub(first); got != row.gap {
|
|
t.Errorf("the second attempt began %v after the first, want %v", got, row.gap)
|
|
}
|
|
if err := run.await(t, "Run to end after the second attempt"); err != nil {
|
|
t.Errorf("Run = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestRunWakeEndsTheWait(t *testing.T) {
|
|
rows := []wakeRow{
|
|
{name: "a wake right after the attempt waits for the second", took: 0, gap: time.Second},
|
|
{name: "a wake within the second waits for the rest of it", took: 300 * time.Millisecond, gap: time.Second},
|
|
{name: "a wake after the second starts the attempt at once", took: 4 * time.Second, gap: 4 * time.Second},
|
|
}
|
|
for _, row := range rows {
|
|
t.Run(row.name, func(t *testing.T) { runWakeRow(t, row) })
|
|
}
|
|
}
|
|
|
|
func TestRunAtCapWaitsTheCap(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
clock := newClock(false)
|
|
// The wake channel is buffered as the discovery loop's is. A loop that
|
|
// listened for a wake during an AtCap wait would take it as soon as it is
|
|
// sent and start a one-second timer; this one leaves it unread.
|
|
wake := make(chan struct{}, 1)
|
|
began := make(chan time.Time, 4)
|
|
calls := 0
|
|
drawn, expected := seeded()
|
|
p := Policy{Clock: clock, Rand: drawn, Wake: wake, Logger: slog.New(&recordingHandler{})}
|
|
run := start(t.Context(), p, func(context.Context) error {
|
|
began <- clock.Now()
|
|
calls++
|
|
if calls == 1 {
|
|
return AtCap(errors.New("unusable"))
|
|
}
|
|
return nil
|
|
})
|
|
first := receive(t, began, "the first attempt")
|
|
wait := nextTimer(t, clock, "the wait after an AtCap failure")
|
|
wantWait := DefaultCap + time.Duration(expected.Float64()*float64(DefaultCap)/2)
|
|
if wait.d != wantWait {
|
|
t.Fatalf("wait after an AtCap failure = %v, want the cap plus the seeded jitter, %v", wait.d, wantWait)
|
|
}
|
|
synctest.Wait() // the loop is blocked in the wait after the AtCap failure
|
|
wake <- struct{}{}
|
|
synctest.Wait() // a loop that listens for the wake has taken it by now
|
|
if len(wake) != 1 || len(clock.timers) != 0 {
|
|
t.Fatalf("a wake during an AtCap wait was read")
|
|
}
|
|
// The next attempt starts only when the fake clock reaches the cap plus the
|
|
// jitter, which firing the timer does.
|
|
wait.fire()
|
|
second := receive(t, began, "the second attempt")
|
|
if got := second.Sub(first); got != wantWait {
|
|
t.Errorf("the second attempt began %v after the first, want the cap plus the seeded jitter, %v", got, wantWait)
|
|
}
|
|
if err := run.await(t, "Run to end after the second attempt"); err != nil {
|
|
t.Errorf("Run = %v, want nil", err)
|
|
}
|
|
if len(clock.waits) != 1 {
|
|
t.Errorf("waits = %v, want only the cap's wait; a wake must not start a shorter one", clock.waits)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRunAtCapPinsOnlyItsOwnWait(t *testing.T) {
|
|
clock := newClock(true)
|
|
calls := 0
|
|
p := Policy{Clock: clock, Rand: noJitter{}, Logger: slog.New(&recordingHandler{})}
|
|
err := Run(t.Context(), p, func(context.Context) error {
|
|
calls++
|
|
switch calls {
|
|
case 1:
|
|
return AtCap(errors.New("unusable"))
|
|
case 2:
|
|
return errors.New("refused")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run = %v, want nil", err)
|
|
}
|
|
want := []time.Duration{DefaultCap, 2 * DefaultFirstWait}
|
|
if len(clock.waits) != len(want) || clock.waits[0] != want[0] || clock.waits[1] != want[1] {
|
|
t.Errorf("waits = %v, want %v: the cap after the AtCap failure, then the ordinary sequence", clock.waits, want)
|
|
}
|
|
}
|
|
|
|
func TestRunEndedContextMakesNoAttempt(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
calls := 0
|
|
err := Run(ctx, Policy{Clock: newClock(true), Logger: slog.New(&recordingHandler{})}, func(context.Context) error {
|
|
calls++
|
|
return nil
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("Run = %v, want context.Canceled", err)
|
|
}
|
|
if calls != 0 {
|
|
t.Errorf("a context that had ended made %d attempts, want none", calls)
|
|
}
|
|
}
|
|
|
|
func runFailureForm(t *testing.T, failure error) {
|
|
clock := newClock(true)
|
|
handler := &recordingHandler{}
|
|
p := Policy{Clock: clock, Rand: noJitter{}, Logger: slog.New(handler), FailureMessage: "policy message"}
|
|
calls := 0
|
|
err := Run(t.Context(), p, func(context.Context) error {
|
|
calls++
|
|
if calls == 1 {
|
|
return failure
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run = %v, want nil", err)
|
|
}
|
|
lines := handler.lines()
|
|
if len(lines) == 0 {
|
|
t.Fatal("nothing was logged")
|
|
}
|
|
if lines[0].level != slog.LevelError || lines[0].message != "unusable" {
|
|
t.Errorf("failure line is %v %q, want Error \"unusable\"", lines[0].level, lines[0].message)
|
|
}
|
|
}
|
|
|
|
func TestRunFailureForms(t *testing.T) {
|
|
rows := []struct {
|
|
name string
|
|
failure error
|
|
}{
|
|
{"by value", Failure{Message: "unusable", Level: slog.LevelError, Err: errors.New("bad")}},
|
|
{"by pointer", &Failure{Message: "unusable", Level: slog.LevelError, Err: errors.New("bad")}},
|
|
}
|
|
for _, row := range rows {
|
|
t.Run(row.name, func(t *testing.T) { runFailureForm(t, row.failure) })
|
|
}
|
|
}
|
|
|
|
func TestRunStopsWhenTheContextEnds(t *testing.T) {
|
|
clock := newClock(false)
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
began := make(chan time.Time, 4)
|
|
run := start(ctx, Policy{Clock: clock, Rand: noJitter{}, Logger: slog.New(&recordingHandler{})},
|
|
failing(clock, errors.New("refused"), began))
|
|
receive(t, began, "the first attempt")
|
|
nextTimer(t, clock, "the wait after the first attempt")
|
|
cancel()
|
|
if err := run.await(t, "Run to return after the context ended"); !errors.Is(err, context.Canceled) {
|
|
t.Errorf("Run = %v, want context.Canceled", err)
|
|
}
|
|
select {
|
|
case <-began:
|
|
t.Error("an attempt began after the context ended")
|
|
default:
|
|
}
|
|
}
|
|
|
|
// hour is how long a decay row runs on the fake clock.
|
|
const hour = time.Hour
|
|
|
|
// runForAnHour calls attempt until an hour has passed on the fake clock and
|
|
// returns what was logged.
|
|
func runForAnHour(t *testing.T, p Policy, attempt func(n int) error) []logLine {
|
|
t.Helper()
|
|
clock := newClock(true)
|
|
handler := &recordingHandler{}
|
|
p.Clock, p.Logger = clock, slog.New(handler)
|
|
drawn, _ := seeded()
|
|
p.Rand = drawn
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
began := clock.Now()
|
|
n := 0
|
|
err := Run(ctx, p, func(context.Context) error {
|
|
n++
|
|
if clock.Now().Sub(began) >= hour {
|
|
cancel()
|
|
}
|
|
return attempt(n)
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run = %v, want context.Canceled at the end of the hour", err)
|
|
}
|
|
return handler.lines()
|
|
}
|
|
|
|
func checkLinesCarry(t *testing.T, lines []logLine) {
|
|
t.Helper()
|
|
for i, line := range lines {
|
|
for _, key := range []string{"attempts", "elapsed", "retry_in", "error"} {
|
|
if _, ok := line.attrs[key]; !ok {
|
|
t.Errorf("line %d (%q) has no %s attribute", i+1, line.message, key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func decaySteadyError(t *testing.T) {
|
|
lines := runForAnHour(t, Policy{FailureMessage: "refused"}, func(int) error { return errors.New("connection refused") })
|
|
checkLinesCarry(t, lines)
|
|
if len(lines) > 14 {
|
|
t.Errorf("an hour of one error wrote %d lines, want at most 14", len(lines))
|
|
}
|
|
if len(lines) == 0 || lines[0].level != slog.LevelWarn {
|
|
t.Fatalf("the first line must be Warn, got %v", lines)
|
|
}
|
|
firstError := -1
|
|
for i, line := range lines {
|
|
wantLevel := slog.LevelWarn
|
|
if line.elapsed() >= DefaultEscalation {
|
|
wantLevel = slog.LevelError
|
|
}
|
|
if line.level != wantLevel {
|
|
t.Errorf("line %d at %v is %v, want %v", i+1, line.elapsed(), line.level, wantLevel)
|
|
}
|
|
if line.level == slog.LevelError && firstError < 0 {
|
|
firstError = i
|
|
}
|
|
}
|
|
if firstError < 0 {
|
|
t.Fatal("no line was written at Error after the escalation time")
|
|
}
|
|
if got := lines[firstError].elapsed(); got >= DefaultEscalation+DefaultCap*2 {
|
|
t.Errorf("the first Error line was written at %v, want when %v passed", got, DefaultEscalation)
|
|
}
|
|
}
|
|
|
|
func decayChangingError(t *testing.T) {
|
|
lines := runForAnHour(t, Policy{FailureMessage: "refused", Escalation: 24 * hour}, func(n int) error {
|
|
return fmt.Errorf("refused on attempt %d", n)
|
|
})
|
|
checkLinesCarry(t, lines)
|
|
if len(lines) > 121 {
|
|
t.Errorf("an hour of changing errors wrote %d lines, want at most 121", len(lines))
|
|
}
|
|
for i := 1; i < len(lines); i++ {
|
|
if gap := lines[i].elapsed() - lines[i-1].elapsed(); gap < 30*time.Second {
|
|
t.Errorf("lines %d and %d are %v apart, want at least 30s", i, i+1, gap)
|
|
}
|
|
}
|
|
}
|
|
|
|
func decayFailureLevel(t *testing.T) {
|
|
lines := runForAnHour(t, Policy{FailureMessage: "unused"}, func(int) error {
|
|
return Failure{Message: "unusable", Level: slog.LevelError, Err: errors.New("bad document")}
|
|
})
|
|
checkLinesCarry(t, lines)
|
|
if len(lines) > 14 {
|
|
t.Errorf("an hour of an Error failure wrote %d lines, want at most 14", len(lines))
|
|
}
|
|
for i, line := range lines {
|
|
if line.level != slog.LevelError || line.message != "unusable" {
|
|
t.Errorf("line %d is %v %q, want Error \"unusable\"", i+1, line.level, line.message)
|
|
}
|
|
}
|
|
if len(lines) == 0 {
|
|
t.Fatal("nothing was logged")
|
|
}
|
|
}
|
|
|
|
func decayRecovery(t *testing.T) {
|
|
clock := newClock(true)
|
|
handler := &recordingHandler{}
|
|
p := Policy{
|
|
Clock: clock, Rand: noJitter{}, Logger: slog.New(handler),
|
|
FailureMessage: "refused", SuccessMessage: "connected", Attrs: []slog.Attr{slog.String("host", "h")},
|
|
}
|
|
n := 0
|
|
began := clock.Now()
|
|
err := Run(t.Context(), p, func(context.Context) error {
|
|
n++
|
|
clock.advance(40 * time.Second)
|
|
if n <= 3 {
|
|
return fmt.Errorf("refused on attempt %d", n)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run = %v, want nil", err)
|
|
}
|
|
lines := handler.lines()
|
|
if len(lines) != 4 {
|
|
t.Fatalf("logged %d lines, want three failures and one success", len(lines))
|
|
}
|
|
checkLinesCarry(t, lines[:3])
|
|
for i, line := range lines[:3] {
|
|
if got := line.attrs["attempts"].Int64(); got != int64(i+1) {
|
|
t.Errorf("failure line %d attempts = %d, want %d", i+1, got, i+1)
|
|
}
|
|
}
|
|
last := lines[3]
|
|
if last.level != slog.LevelInfo || last.message != "connected" {
|
|
t.Errorf("last line is %v %q, want Info \"connected\"", last.level, last.message)
|
|
}
|
|
if got := last.attrs["attempts"].Int64(); got != 4 {
|
|
t.Errorf("success line attempts = %d, want 4", got)
|
|
}
|
|
if got, want := last.elapsed(), clock.Now().Sub(began); got != want {
|
|
t.Errorf("success line elapsed = %v, want %v", got, want)
|
|
}
|
|
if got := last.attrs["host"].String(); got != "h" {
|
|
t.Errorf("success line lost the policy's attribute: host = %q", got)
|
|
}
|
|
}
|
|
|
|
func decayAlternatingLevels(t *testing.T) {
|
|
lines := runForAnHour(t, Policy{FailureMessage: "refused", Escalation: 24 * hour}, func(n int) error {
|
|
if n%2 == 0 {
|
|
return Failure{Level: slog.LevelError, Err: errors.New("unusable")}
|
|
}
|
|
return errors.New("refused")
|
|
})
|
|
if len(lines) < 2 {
|
|
t.Fatalf("wrote %d lines, want at least 2", len(lines))
|
|
}
|
|
for i := 1; i < len(lines); i++ {
|
|
if gap := lines[i].elapsed() - lines[i-1].elapsed(); gap < 30*time.Second {
|
|
t.Errorf("lines %d and %d are %v apart, want at least 30s", i, i+1, gap)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunLogDecay(t *testing.T) {
|
|
rows := []struct {
|
|
name string
|
|
run func(*testing.T)
|
|
}{
|
|
{"an hour of one error", decaySteadyError},
|
|
{"an hour of an error that changes", decayChangingError},
|
|
{"a failure with level Error", decayFailureLevel},
|
|
{"levels that alternate before the escalation", decayAlternatingLevels},
|
|
{"three failures then a success", decayRecovery},
|
|
}
|
|
for _, row := range rows {
|
|
t.Run(row.name, row.run)
|
|
}
|
|
}
|