Store a target's number of delivery attempts, at least one (closes #540)
check / check (push) Canceled after 0s

Target.MaxRetries (max_retries) becomes Target.MaxAttempts
(max_attempts), and the form field, parser and ceiling are renamed to
match. The column is NOT NULL and a check constraint refuses a value
below 1, so every target, whatever its type, is stored with at least
1. 1 is a single attempt with no circuit breaker; above 1 retries
behind the breaker up to that many attempts. Both target forms refuse
0 and less; a blank field on the add target form gives 1, and on the
edit form keeps the stored value. The target page shows the plain
number, and the README's two paragraphs on 0 against 1 become one
sentence.

Model: opus-5-5
This commit is contained in:
2026-10-15 16:55:55 +00:00
parent c7c4ea3340
commit a69edcdb76
51 changed files with 1027 additions and 820 deletions
+34 -24
View File
@@ -32,7 +32,7 @@ const (
viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)"
viewExpiryNever = "never"
viewMaxRetries = "Delivery attempts"
viewMaxAttempts = "Delivery attempts"
)
func TestMaskedWebhookURL(t *testing.T) {
@@ -171,6 +171,7 @@ func slackTarget() database.Target {
Active: true,
Config: `{"webhookUrl":"` +
slackWebhookURL + `"}`,
MaxAttempts: 1,
}
}
@@ -189,28 +190,29 @@ func TestNewTargetViews_Slack(t *testing.T) {
assert.Equal(
t,
map[string]string{
"Webhook URL": slackMaskedURL,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
"Webhook URL": slackMaskedURL,
viewMaxAttempts: "1",
},
fieldMap(view.Config),
)
}
// TestNewTargetViews_SlackRetries proves a Slack target shows
// its retry count the same way an HTTP target does.
func TestNewTargetViews_SlackRetries(t *testing.T) {
// TestNewTargetViews_SlackAttempts proves a Slack target shows
// its number of delivery attempts the same way an HTTP target
// does.
func TestNewTargetViews_SlackAttempts(t *testing.T) {
t.Parallel()
target := slackTarget()
target.MaxRetries = 2
target.MaxAttempts = 2
view := viewFor(t, target)
assert.Equal(
t,
map[string]string{
"Webhook URL": slackMaskedURL,
viewMaxRetries: "2",
"Webhook URL": slackMaskedURL,
viewMaxAttempts: "2",
},
fieldMap(view.Config),
)
@@ -225,7 +227,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
`"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"},` +
`"forwardQuery":true}`,
MaxRetries: 5,
MaxAttempts: 5,
})
fields := fieldMap(view.Config)
@@ -237,7 +239,7 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Timeout": "30s",
"Headers": "1 configured",
"Query string": "passed on to this target",
viewMaxRetries: "5",
viewMaxAttempts: "5",
},
fields,
)
@@ -248,19 +250,22 @@ func TestNewTargetViews_HTTP(t *testing.T) {
}
}
func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
// TestNewTargetViews_HTTPSingleAttempt proves a single attempt is
// shown as the plain number, like any other.
func TestNewTargetViews_HTTPSingleAttempt(t *testing.T) {
t.Parallel()
view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP,
Config: `{"url":"` + viewExampleHook + `"}`,
Type: database.TargetTypeHTTP,
Config: `{"url":"` + viewExampleHook + `"}`,
MaxAttempts: 1,
})
assert.Equal(
t,
map[string]string{
"Destination URL": viewMaskedOrigin,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
viewMaxAttempts: "1",
},
fieldMap(view.Config),
)
@@ -274,8 +279,9 @@ func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) {
t.Parallel()
view := viewFor(t, database.Target{
Type: database.TargetTypeHTTP,
Config: `{"url":"` + slackWebhookURL + `"}`,
Type: database.TargetTypeHTTP,
Config: `{"url":"` + slackWebhookURL + `"}`,
MaxAttempts: 1,
})
fields := fieldMap(view.Config)
@@ -398,19 +404,23 @@ func TestNewTargetViews_Unpresentable(t *testing.T) {
Config: blob,
},
"unparseable json": {
Type: database.TargetTypeSlack,
Config: blob,
Type: database.TargetTypeSlack,
Config: blob,
MaxAttempts: 1,
},
"empty slack config": {
Type: database.TargetTypeSlack,
Type: database.TargetTypeSlack,
MaxAttempts: 1,
},
"slack config without url": {
Type: database.TargetTypeSlack,
Config: `{}`,
Type: database.TargetTypeSlack,
Config: `{}`,
MaxAttempts: 1,
},
"unparseable http json": {
Type: database.TargetTypeHTTP,
Config: `{"url":`,
Type: database.TargetTypeHTTP,
Config: `{"url":`,
MaxAttempts: 1,
},
"unparseable archive json": {
Type: database.TargetTypeDatabase,