Brings `prod`, which upaas deploys, up to `main` at `9cf9cdd`, the merge of #321. `prod` was cut from `main` at `251cb3d` (1.0.0b1). What it deploys is everything listed in #321. For running it: - With `WEBHOOKER_ENVIRONMENT` unset, the instance runs as `prod` and sends no `Access-Control-Allow-Origin: *`. - Each event database gains its new indexes the first time it is opened after the upgrade. - `webhooker_delivery_retries_total` no longer counts a circuit breaker holding back a delivery that is already `retrying`. Not in this PR yet: #340, in which the container sets its own data directory owner and mode before start. It is in progress on `next`. Once it reaches `main`, this PR carries it, because the PR follows `main`. Model: opus-5-5 Co-authored-by: Jeffrey Paul <1+sneak@noreply.example.org> Reviewed-on: #343
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()
|
||||
|
||||
@@ -1070,6 +1166,10 @@ func TestIsForwardableHeader(t *testing.T) {
|
||||
assert.False(t,
|
||||
delivery.ExportIsForwardableHeader("Content-Length"),
|
||||
)
|
||||
|
||||
assert.False(t,
|
||||
delivery.ExportIsForwardableHeader("Content-Type"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
@@ -1151,6 +1251,81 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// The event's stored inbound headers carry the same Content-Type the
|
||||
// receiver saved as the event's ContentType, so a delivery could send
|
||||
// it twice. It must go out exactly once, with a Content-Type configured
|
||||
// on the target winning, then the event's ContentType.
|
||||
func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]struct {
|
||||
inbound string
|
||||
event string
|
||||
configured string
|
||||
want []string
|
||||
}{
|
||||
"inbound and event agree": {
|
||||
inbound: testContentType,
|
||||
event: testContentType,
|
||||
want: []string{testContentType},
|
||||
},
|
||||
"inbound and event disagree": {
|
||||
inbound: "text/plain",
|
||||
event: testContentType,
|
||||
want: []string{testContentType},
|
||||
},
|
||||
"event has none": {
|
||||
inbound: testContentType,
|
||||
want: nil,
|
||||
},
|
||||
"target configures its own": {
|
||||
inbound: testContentType,
|
||||
event: testContentType,
|
||||
configured: "application/xml",
|
||||
want: []string{"application/xml"},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inbound, err := json.Marshal(map[string][]string{
|
||||
headerContentType: {tc.inbound},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &delivery.HTTPTargetConfig{}
|
||||
if tc.configured != "" {
|
||||
cfg.Headers = map[string]string{
|
||||
headerContentType: tc.configured,
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.Background(),
|
||||
http.MethodPost,
|
||||
"https://target.example.com/hook",
|
||||
http.NoBody,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
delivery.ExportApplyRequestHeaders(
|
||||
req,
|
||||
&database.Event{
|
||||
Headers: string(inbound),
|
||||
ContentType: tc.event,
|
||||
},
|
||||
cfg,
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
tc.want, req.Header.Values(headerContentType),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDelivery_RoutesToCorrectHandler(
|
||||
t *testing.T,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user