Make deliveries refused while half-open wait a cooldown (closes #306)
check / check (push) Successful in 4m59s
check / check (push) Successful in 4m59s
While the breaker was half-open, Allow refused every delivery but the probe and CooldownRemaining returned zero, so each queued task for the target went straight back onto the retry channel and rewrote its status on every pass until the probe finished. CooldownRemaining now returns the whole cooldown while half-open, so a refused delivery waits that long. A refused delivery already at retrying is not written again, so the retry counter now moves only when a refusal moves a delivery into retrying. Model: opus-5-5
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
)
|
||||
|
||||
// testContentType is the event content type used in tests.
|
||||
@@ -894,6 +896,100 @@ func TestDeliverHTTP_CircuitBreakerBlocks(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// recordingScheduler keeps the delay of every retry it is asked to
|
||||
// schedule, and schedules nothing.
|
||||
type recordingScheduler struct {
|
||||
delays []time.Duration
|
||||
}
|
||||
|
||||
func (s *recordingScheduler) ScheduleRetry(
|
||||
_ delivery.Task, delay time.Duration,
|
||||
) {
|
||||
s.delays = append(s.delays, delay)
|
||||
}
|
||||
|
||||
// TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks proves that while a
|
||||
// half-open breaker's one probe delivery is in flight, every other task
|
||||
// for the target is put back with a whole cooldown as its delay rather
|
||||
// than none, and that its status is written the first time the breaker
|
||||
// turns it away and not on each pass after that.
|
||||
func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := testWebhookDB(t)
|
||||
e := testEngine(t, 1)
|
||||
|
||||
// Every write of retrying moves the retry counter, so on a registry
|
||||
// this test owns the counter is the number of those writes.
|
||||
reg := prometheus.NewRegistry()
|
||||
e.ExportSetMetrics(metrics.New(reg))
|
||||
|
||||
targetID := uuid.New().String()
|
||||
cb := newShortCooldownCB(t)
|
||||
e.ExportSetCircuitBreaker(targetID, cb)
|
||||
|
||||
for range delivery.ExportDefaultFailureThreshold {
|
||||
cb.RecordFailure()
|
||||
}
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
require.True(t, cb.Allow(), "the probe delivery should go through")
|
||||
require.Equal(t, delivery.CircuitHalfOpen, cb.State())
|
||||
|
||||
cfg := newHTTPTargetConfig(
|
||||
"http://will-not-be-called.invalid",
|
||||
)
|
||||
sched := &recordingScheduler{}
|
||||
|
||||
const queued, passes = 3, 4
|
||||
|
||||
for range queued {
|
||||
event := seedEvent(t, db, `{"cb":"half-open"}`)
|
||||
dlv := seedDelivery(
|
||||
t, db, event.ID, targetID,
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
for range passes {
|
||||
// Each pass starts from the stored row, as a retry does.
|
||||
var row database.Delivery
|
||||
|
||||
require.NoError(t, db.First(
|
||||
&row, "id = ?", dlv.ID,
|
||||
).Error)
|
||||
|
||||
fix := buildHTTPFixture(
|
||||
row, event, targetID,
|
||||
"test-cb-half-open", cfg, 5, 1,
|
||||
)
|
||||
|
||||
e.ExportDeliverHTTPWithScheduler(
|
||||
context.TODO(), db, fix.Delivery, fix.Task, sched,
|
||||
)
|
||||
}
|
||||
|
||||
assertDeliveryStatus(t, db, dlv.ID,
|
||||
database.DeliveryStatusRetrying,
|
||||
)
|
||||
}
|
||||
|
||||
require.Len(t, sched.delays, queued*passes)
|
||||
|
||||
for _, delay := range sched.delays {
|
||||
// The cooldown newShortCooldownCB gives the breaker.
|
||||
assert.Equal(t, 50*time.Millisecond, delay,
|
||||
"a task turned away while half-open should wait "+
|
||||
"a whole cooldown",
|
||||
)
|
||||
}
|
||||
|
||||
assert.InDelta(t, float64(queued),
|
||||
mCounter(t, reg, mRetries, mTypeHTTP), 0,
|
||||
"status should be written once per task, not once per pass",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user