From f61b608b1c95d3593e104a3fceee34408e2e3eff Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sat, 3 Oct 2026 02:29:03 +0000 Subject: [PATCH] Use one name for each thing the UI shows (closes #399) The database target type is called an archive on its badge, in the add target form's type list and on its edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere, the new webhook page included. The retry field is labelled "Delivery attempts", with its help text and error messages to match, on both target forms and in the target list. The navbar says "Sign out" and the sign-in page "Sign in". The resubmit notice says "webhook". The stored values (`database`, `max_retries`) and their meaning are unchanged. Model: opus-5-5 --- README.md | 21 ++++++----- internal/delivery/target_config_view.go | 6 +-- internal/delivery/target_config_view_test.go | 8 ++-- internal/handlers/archive_expiry_test.go | 2 +- internal/handlers/archive_rotation_test.go | 2 +- internal/handlers/auth.go | 2 +- internal/handlers/notice.go | 2 +- internal/handlers/page_title_test.go | 2 +- internal/handlers/source_detail_test.go | 9 ++++- internal/handlers/source_management.go | 2 +- internal/handlers/target_edit_test.go | 26 ++++++++++++- internal/handlers/target_retries.go | 6 +-- internal/handlers/ui_copy_test.go | 39 ++++++++++++++++---- internal/server/alpine_browser_test.go | 30 ++++++++------- internal/server/resubmit_test.go | 6 +-- internal/server/routes_test.go | 22 ++++++++++- templates/login.html | 4 +- templates/navbar.html | 4 +- templates/source_detail.html | 12 +++--- templates/sources_new.html | 4 +- templates/target_edit.html | 10 ++--- 21 files changed, 150 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index c026e6b..8382203 100644 --- a/README.md +++ b/README.md @@ -1617,10 +1617,11 @@ event routing. The new webhook form can also give the webhook its first targets: an optional HTTP target URL creates an `http` target named `HTTP`, and the archive checkbox creates a `database` target named `Archive` whose -`expiry` is the pruning chosen beside it (never, 1h, 12h, 24h, 30d, 90d -or 365d) and whose `rotation` is the rotation chosen below that (none, -monthly, daily or hourly). Both are validated as on the add target form, -and the webhook and its targets are created together or not at all. +`expiry` is the archive expiry chosen beside it (never, 1h, 12h, 24h, +30d, 90d or 365d) and whose `rotation` is the rotation chosen below that +(none, monthly, daily or hourly). Both are validated as on the add +target form, and the webhook and its targets are created together or +not at all. | Field | Type | Description | | ---------------- | ------- | ----------- | @@ -1707,7 +1708,7 @@ events should be forwarded. | `type` | TargetType | One of: `http`, `slack`, `database`, `log` | | `active` | boolean | Whether deliveries are enabled (default: true) | | `config` | JSON text | Type-specific configuration | -| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets | +| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts | **Relations:** Belongs to Webhook. Has many Deliveries. @@ -1736,7 +1737,9 @@ events should be forwarded. expiry in plain units, such as "30 days", and the rotation. No external delivery and no retries; an archive write failure fails the delivery. See the database target section under "Per-Webhook Event Databases" - for the full semantics. + for the full semantics. The web UI calls this type an archive: its + badge, the add target form's type list and the target edit page say + so, and its settings are labelled Archive expiry and Archive rotation. - **`log`** — Write the event to the application log (stdout). Useful for debugging. @@ -3081,9 +3084,9 @@ abuse limit later; they are tracked as future work. | Method | Path | Description | | ------ | --------------- | ----------- | -| `GET` | `/pages/login` | Login page (not rate limited). Its `next` parameter names the page to return to after login; anything but a path on this site is replaced with `/` | -| `POST` | `/pages/login` | Login form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) | -| `POST` | `/pages/logout` | Logout (destroys session) | +| `GET` | `/pages/login` | Sign-in page (not rate limited). Its `next` parameter names the page to return to after signing in; anything but a path on this site is replaced with `/` | +| `POST` | `/pages/login` | Sign-in form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) | +| `POST` | `/pages/logout` | Sign out (destroys session) | #### Authenticated Endpoints diff --git a/internal/delivery/target_config_view.go b/internal/delivery/target_config_view.go index 575706e..d923c00 100644 --- a/internal/delivery/target_config_view.go +++ b/internal/delivery/target_config_view.go @@ -185,7 +185,7 @@ func maxRetriesField(t *database.Target) ConfigField { } return ConfigField{ - Label: "Max Retries", + Label: "Delivery attempts", Value: retries, } } @@ -213,10 +213,10 @@ func databaseConfigFields(configJSON string) []ConfigField { } return []ConfigField{{ - Label: "Archive Expiry", + Label: "Archive expiry", Value: value, }, { - Label: "Archive Rotation", + Label: "Archive rotation", Value: rotation, }} } diff --git a/internal/delivery/target_config_view_test.go b/internal/delivery/target_config_view_test.go index 923bd78..d10825f 100644 --- a/internal/delivery/target_config_view_test.go +++ b/internal/delivery/target_config_view_test.go @@ -32,7 +32,7 @@ const ( viewMaskedOrigin = viewExampleOrigin + "/..." viewUnavailable = "(unavailable)" viewExpiryNever = "never" - viewMaxRetries = "Max Retries" + viewMaxRetries = "Delivery attempts" ) func TestMaskedWebhookURL(t *testing.T) { @@ -329,8 +329,8 @@ func TestNewTargetViews_Database(t *testing.T) { assert.Equal( t, map[string]string{ - "Archive Expiry": tc.want, - "Archive Rotation": rotationNone, + "Archive expiry": tc.want, + "Archive rotation": rotationNone, }, fieldMap(view.Config), ) @@ -365,7 +365,7 @@ func TestNewTargetViews_DatabaseRotation(t *testing.T) { }) assert.Equal( - t, want, fieldMap(view.Config)["Archive Rotation"], + t, want, fieldMap(view.Config)["Archive rotation"], ) }) } diff --git a/internal/handlers/archive_expiry_test.go b/internal/handlers/archive_expiry_test.go index 9484543..d740f2d 100644 --- a/internal/handlers/archive_expiry_test.go +++ b/internal/handlers/archive_expiry_test.go @@ -45,7 +45,7 @@ func expiryShown( require.Equal(t, http.StatusOK, w.Code) return matched( - `Archive Expiry:\s*([^<]*)`, w.Body.String(), + `Archive expiry:\s*([^<]*)`, w.Body.String(), ) } diff --git a/internal/handlers/archive_rotation_test.go b/internal/handlers/archive_rotation_test.go index 177373c..be09990 100644 --- a/internal/handlers/archive_rotation_test.go +++ b/internal/handlers/archive_rotation_test.go @@ -27,7 +27,7 @@ func rotationShown( t.Helper() return matched( - `Archive Rotation:\s*([^<]*)`, + `Archive rotation:\s*([^<]*)`, renderedPage(t, env, webhookID), ) } diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index f22dd98..7a1b6ac 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -268,7 +268,7 @@ func (h *Handlers) rejectLogin( ))) h.renderLoginError( w, r, - "Too many failed login attempts. Please try again later.", + "Too many failed sign-in attempts. Please try again later.", http.StatusTooManyRequests, ) } diff --git a/internal/handlers/notice.go b/internal/handlers/notice.go index 69bb88e..8e2e2ae 100644 --- a/internal/handlers/notice.go +++ b/internal/handlers/notice.go @@ -96,7 +96,7 @@ func noticeFor(r *http.Request) *notice { }, resubmitNoTargets: { Text: "Resubmitted: a new event was created, but this " + - "source has no active targets, so nothing was queued.", + "webhook has no active targets, so nothing was queued.", }, }[noticeCode(r.URL.Query().Get(noticeParam))] if !ok { diff --git a/internal/handlers/page_title_test.go b/internal/handlers/page_title_test.go index 8c11f98..441bd1a 100644 --- a/internal/handlers/page_title_test.go +++ b/internal/handlers/page_title_test.go @@ -40,7 +40,7 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) { data map[string]any title string }{ - {"login.html", map[string]any{}, "Login - Webhooker"}, + {"login.html", map[string]any{}, "Sign in - Webhooker"}, {"profile.html", map[string]any{}, "Profile - Webhooker"}, {"settings.html", map[string]any{}, "Settings - Webhooker"}, {"sources_list.html", map[string]any{}, "Webhooks - Webhooker"}, diff --git a/internal/handlers/source_detail_test.go b/internal/handlers/source_detail_test.go index 2d58837..a77129b 100644 --- a/internal/handlers/source_detail_test.go +++ b/internal/handlers/source_detail_test.go @@ -233,8 +233,13 @@ func TestHandleSourceDetail_RendersNamedTargetFields( assert.Contains(t, body, "1 configured") assert.NotContains(t, body, "sekrit") - assert.Contains(t, body, "Archive Expiry") - assert.Contains(t, body, "30 days") + // The database type is called an archive: on its badge, in the + // add target form's type list and in its settings. + list := targetList(t, body) + assert.Contains(t, list, "t-database archive Active") + assert.Contains(t, list, "Archive expiry: 30 days") + assert.Contains(t, list, "Archive rotation: none") + assert.Contains(t, body, ``) // An unknown type gets the neutral placeholder, never the // stored blob. diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 6959aba..e8130d3 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1749,7 +1749,7 @@ func (h *Handlers) setTargetFromForm( // cannot destroy the count a target is delivering with. maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries) if err != nil { - return "Invalid max retries: " + retriesErrorMessage(err), nil + return "Invalid delivery attempts: " + retriesErrorMessage(err), nil } target.Name = in.Name diff --git a/internal/handlers/target_edit_test.go b/internal/handlers/target_edit_test.go index 1c7c4d4..9580252 100644 --- a/internal/handlers/target_edit_test.go +++ b/internal/handlers/target_edit_test.go @@ -418,6 +418,30 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked( assert.Contains(t, page, "original-name") } +// TestHandleTargetEdit_CallsTheDatabaseTypeArchive pins the names the +// edit page of a database target gives its type and its settings to +// the ones its badge and the add target form use. +func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) { + t.Parallel() + + env := setupSourceTest(t) + webhook := seedWebhookWithRetention(t, env.db, 30) + target := seedTarget(t, env.db, webhook.ID, database.TargetTypeDatabase) + + w := serveTarget( + env, http.MethodGet, + "/hook/"+webhook.ID+"/targets/"+target.ID+"/edit", + nil, + ) + require.Equal(t, http.StatusOK, w.Code) + + page := w.Body.String() + + assert.Contains(t, page, "Type: archive.") + assert.Contains(t, page, `class="label">Archive expiry`) + assert.Contains(t, page, `class="label">Archive rotation`) +} + // TestHandleTargetEditSubmit_Rejects covers every submission that // must not reach storage. // @@ -588,7 +612,7 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) { { database.TargetTypeSlack, "name=edited&url=" + editOriginalURL + "&max_retries=25", - "Invalid max retries", + "Invalid delivery attempts", }, { database.TargetTypeDatabase, diff --git a/internal/handlers/target_retries.go b/internal/handlers/target_retries.go index 8b68780..15979c3 100644 --- a/internal/handlers/target_retries.go +++ b/internal/handlers/target_retries.go @@ -25,14 +25,14 @@ var ( // errRetriesInvalid signals a max_retries form value that is not // a non-negative whole number. errRetriesInvalid = errors.New( - "retries must be a whole number of attempts", + "must be a whole number", ) // errRetriesTooLarge signals a max_retries form value that is a // whole number but above maxTargetRetries. It is distinguished // from errRetriesInvalid so the message can name the ceiling // instead of implying the input was not a number. - errRetriesTooLarge = errors.New("retries out of range") + errRetriesTooLarge = errors.New("out of range") ) // parseMaxRetries interprets a max_retries form value. @@ -82,7 +82,7 @@ func retriesErrorMessage(err error) string { if errors.Is(err, errRetriesTooLarge) { return errRetriesTooLarge.Error() + ": at most " + strconv.Itoa(maxTargetRetries) + - " retries" + " attempts" } return errRetriesInvalid.Error() + diff --git a/internal/handlers/ui_copy_test.go b/internal/handlers/ui_copy_test.go index 21800d6..8b9e655 100644 --- a/internal/handlers/ui_copy_test.go +++ b/internal/handlers/ui_copy_test.go @@ -354,15 +354,14 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) { // and target_http gives up once the attempt number reaches // max_retries), and 0 is special-cased to a single fire-and-forget // attempt with no circuit breaker. -const maxRetriesHelp = "This is the total number of delivery attempts, " + - "not retries on top of the first: a value of 3 makes three attempts " + - "in all. 0 means a single attempt with no retries and no circuit " + - "breaker." +const maxRetriesHelp = "How many times each delivery is attempted in " + + "all, the first attempt included. 0 means a single attempt with no " + + "retries and no circuit breaker." // TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries -// help text on both the create form (the add-target form on the webhook -// detail page) and the edit form, so the copy cannot drift back to -// calling the number a retry count. +// label, "Delivery attempts", and help text on both the create form +// (the add-target form on the webhook detail page) and the edit form, +// so the copy cannot drift back to calling the number a retry count. func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { t.Parallel() @@ -393,6 +392,7 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { }, ) + assert.Contains(t, createBody, "Delivery attempts:") assert.Contains( t, createBody, maxRetriesHelp, "the add-target form must explain max_retries as total attempts", @@ -419,8 +419,33 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) { }, ) + assert.Contains(t, editBody, `class="label">Delivery attempts`) assert.Contains( t, editBody, maxRetriesHelp, "the target edit form must explain max_retries as total attempts", ) } + +// TestCreateFormCallsTheDatabaseTargetAnArchive pins the names the new +// webhook page gives the database target its Archive checkbox creates, +// and that target's settings, to the ones the target forms use. +func TestCreateFormCallsTheDatabaseTargetAnArchive(t *testing.T) { + t.Parallel() + + var h *handlers.Handlers + + var sess *session.Session + + app := newTestApp(t, &h, &sess) + app.RequireStart() + + t.Cleanup(app.RequireStop) + + body := renderPage(t, h, sess, "sources_new.html", map[string]any{ + dataKeyError: "", + }) + + assert.Contains(t, body, "created with an archive target") + assert.Contains(t, body, `class="label">Archive expiry`) + assert.Contains(t, body, `class="label">Archive rotation`) +} diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index 6602847..6677cef 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -318,31 +318,35 @@ const ( func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) { t.Helper() - // Each target type, with the fields its add target form submits, in - // page order. Only http and slack have a url field. + // Each target type, with the badge its targets are listed with and + // the fields its add target form submits, in page order. Only http + // and slack have a url field. targetTypes := []struct { name string + badge string fields string values map[string]string }{ { - "http", "csrf_token name type url headers timeout max_retries", + "http", "http", + "csrf_token name type url headers timeout max_retries", map[string]string{"url": publicTargetURL}, }, { - "slack", "csrf_token name type url max_retries", + "slack", "slack", "csrf_token name type url max_retries", map[string]string{"url": publicTargetURL}, }, { - "database", "csrf_token name type expiry rotation", + "database", "archive", "csrf_token name type expiry rotation", map[string]string{"expiry": "720h", "rotation": "daily"}, }, - {"log", "csrf_token name type", nil}, + {"log", "log", "csrf_token name type", nil}, } for _, tt := range targetTypes { checkAddTarget( - ctx, t, url, tt.name, strings.Fields(tt.fields), tt.values, + ctx, t, url, tt.name, tt.badge, + strings.Fields(tt.fields), tt.values, ) } } @@ -353,11 +357,11 @@ func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) { // type's own fields in place of the choice, and the form then submits // exactly fields, so a field another type uses, such as url, is absent; // Cancel closes it again. It then adds a target of the type, filling in -// values, and checks that the section lists it with that type. +// values, and checks that the section lists it with badge. func checkAddTarget( ctx context.Context, t *testing.T, - url, targetType string, + url, targetType, badge string, fields []string, values map[string]string, ) { @@ -412,8 +416,8 @@ func checkAddTarget( click(ctx, t, saveButton) assert.Truef(t, shown(ctx, `//span[text()="`+name+ - `"]/following-sibling::div/span[text()="`+targetType+`"]`), - "%s: the added target is not listed with its type", targetType) + `"]/following-sibling::div/span[text()="`+badge+`"]`), + "%s: the added target is not listed as %s", targetType, badge) } // chooseTargetType clicks Add, picks targetType and clicks Next, and @@ -466,10 +470,10 @@ func checkArchiveChoices(ctx context.Context, t *testing.T, url string) { assert.Equal(t, "none", startRotation, "the add target form's archive rotation does not start on none") - assert.True(t, shown(ctx, row+`//span[text()="Archive Expiry:"]`+ + assert.True(t, shown(ctx, row+`//span[text()="Archive expiry:"]`+ `/following-sibling::span[text()="30 days"]`), "a database target added with 720h is not listed as 30 days") - assert.True(t, shown(ctx, row+`//span[text()="Archive Rotation:"]`+ + assert.True(t, shown(ctx, row+`//span[text()="Archive rotation:"]`+ `/following-sibling::span[text()="daily"]`), "a database target added with daily is not listed as daily") diff --git a/internal/server/resubmit_test.go b/internal/server/resubmit_test.go index a5ed43a..e5abb61 100644 --- a/internal/server/resubmit_test.go +++ b/internal/server/resubmit_test.go @@ -72,7 +72,7 @@ func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit( env.requireNotice( t, env.post(path, csrfForm(token), cookies), logsPath, "resubmit-no-targets", - "this source has no active targets", cookies, + "this webhook has no active targets", cookies, ) } @@ -117,7 +117,7 @@ func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) { env.requireNotice( t, env.post(path, csrfForm(token), cookies), logsPath, "resubmit-no-targets", - "this source has no active targets", cookies, + "this webhook has no active targets", cookies, ) } @@ -171,7 +171,7 @@ func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) { csrfForm(token), cookies, ), intrudersLogs, "resubmit-no-targets", - "this source has no active targets", cookies, + "this webhook has no active targets", cookies, ) } diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 3d0dd62..27e8cc9 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -914,6 +914,26 @@ func TestPagesLogout_SaysSignedOut(t *testing.T) { env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil) } +// TestSignInAndSignOutWording pins one wording for both: the sign-in +// page's button reads "Sign in" and the navbar's buttons "Sign out", as +// the sign-in page's heading, the error page's link and the notice +// after signing out do. +func TestSignInAndSignOutWording(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + + assert.Contains( + t, env.get("/pages/login", nil).Body.String(), ">Sign in", + ) + + userID, _ := env.seedUser(t, "reader", "somepassword") + page := env.get("/hooks", env.authCookies(t, userID, "reader")).Body.String() + + assert.Equal(t, 2, strings.Count(page, ">Sign out"), + "the desktop and the mobile navbar each say Sign out") +} + // --- /user/{username} group --- // TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged @@ -1363,7 +1383,7 @@ func TestHook_ResubmitFromEventLog(t *testing.T) { ) env.requireNotice( t, w, logsPath, "resubmit-no-targets", - "this source has no active targets", cookies, + "this webhook has no active targets", cookies, ) webhookDB, err := env.dbMgr.GetDB(wh.ID) diff --git a/templates/login.html b/templates/login.html index 6fd64f8..c68b9b0 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,6 +1,6 @@ {{template "base" .}} -{{define "title"}}Login - Webhooker{{end}} +{{define "title"}}Sign in - Webhooker{{end}} {{define "content"}}
@@ -52,7 +52,7 @@ >
- + diff --git a/templates/navbar.html b/templates/navbar.html index 7e1f03f..7b6e616 100644 --- a/templates/navbar.html +++ b/templates/navbar.html @@ -32,7 +32,7 @@ {{if .CSRFToken}}
- +
{{end}} {{end}} @@ -49,7 +49,7 @@ {{if .CSRFToken}}
- +
{{end}} {{end}} diff --git a/templates/source_detail.html b/templates/source_detail.html index edcc187..54403c3 100644 --- a/templates/source_detail.html +++ b/templates/source_detail.html @@ -159,7 +159,7 @@ @@ -182,10 +182,10 @@
- +
-

This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.

+

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

@@ -198,10 +198,10 @@
- +
-

This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.

+

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

@@ -251,7 +251,7 @@
{{.Name}}
- {{.Type}} + {{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}} {{if .Active}} Active {{else}} diff --git a/templates/sources_new.html b/templates/sources_new.html index b3964ea..9911b5c 100644 --- a/templates/sources_new.html +++ b/templates/sources_new.html @@ -46,9 +46,9 @@ Archive -

When checked, the webhook is created with a database target that keeps a copy of every event.

+

When checked, the webhook is created with an archive target that keeps a copy of every event.

- + {{range .ArchiveExpiryChoices}} @@ -69,7 +69,7 @@
- + -

This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.

+

How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.

{{end}}