Files
webhooker/internal/handlers/target_retries.go
T
clawbot b9ec91c0f7
check / check (push) Successful in 3m21s
Use one name for each thing the UI shows (closes #399)
The UI gave one thing several names. The `database` type is now Archive in the type list, on its badge and on the edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere. The retry field is "Delivery attempts", with help text, errors and the target list saying it counts every attempt; a stored 0 shows as one attempt. The target list uses one capitalisation. The navbar says "Sign out", the sign-in page "Sign in", and the resubmit notice "webhook" instead of "source". The README follows. Stored values, their meaning, routes and form field names are unchanged.

Model: opus-5-5
2026-10-03 05:07:41 +02:00

91 lines
3.1 KiB
Go

package handlers
import (
"errors"
"strconv"
"strings"
)
// maxTargetRetries bounds a target's max_retries.
//
// Both target forms already declare max="20" on the input, so this
// enforces server-side what the UI has always advertised rather than
// introducing a new limit.
//
// The number is not cosmetic. Every attempt writes a delivery_results
// row that the event log then loads and renders, and the engine backs
// off by 2^(n-1) seconds, so attempt 20 is already about six days
// after the first. A value beyond this buys no additional durability
// and only costs rows.
const maxTargetRetries = 20
// Errors returned when a max_retries form value cannot be turned into
// a retry count.
var (
// errRetriesInvalid signals a max_retries form value that is not
// a non-negative whole number.
errRetriesInvalid = errors.New(
"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("out of range")
)
// parseMaxRetries interprets a max_retries form value.
//
// An ABSENT value — the field empty or not submitted — yields
// fallback, which lets the create path apply its default and the edit
// path leave the stored value alone. A value that is SET BUT INVALID
// is an error: unparseable, negative, or above maxTargetRetries.
//
// The distinction is the whole point of this function. max_retries=0
// means fire-and-forget, so returning 0 for input the operator typed
// but that did not parse silently disables retries on a
// store-and-forward proxy — and on the edit path it destroys a
// working retry configuration over a typo. A default answers a
// question that was not asked; it never answers one that was asked
// badly.
//
// A target stored with a count above the ceiling before this
// validation existed keeps rendering and keeps delivering — nothing
// clamps the row. Re-saving it from the edit form does have to bring
// it into range, because the form submits the pre-filled value back
// and accepting it would be the ceiling not applying to the edit
// path. The 400 names the ceiling, so the fix is one field.
func parseMaxRetries(raw string, fallback int) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return fallback, nil
}
v, err := strconv.Atoi(raw)
if err != nil || v < 0 {
return 0, errRetriesInvalid
}
if v > maxTargetRetries {
return 0, errRetriesTooLarge
}
return v, nil
}
// retriesErrorMessage returns the message the create and edit forms
// show for a rejected max_retries value. Any error other than
// errRetriesTooLarge falls back to the generic wording, so an
// unrecognised parse failure still produces a sensible 400.
func retriesErrorMessage(err error) string {
if errors.Is(err, errRetriesTooLarge) {
return errRetriesTooLarge.Error() +
": at most " + strconv.Itoa(maxTargetRetries) +
" attempts"
}
return errRetriesInvalid.Error() +
", or 0 for fire-and-forget"
}