Store a target's number of delivery attempts, at least one (closes #540)
check / check (push) Canceled after 0s
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. 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 and default a blank field to 1. 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:
@@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxTargetAttempts bounds a target's max_attempts.
|
||||
//
|
||||
// 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 maxTargetAttempts = 20
|
||||
|
||||
// Errors returned when a max_attempts form value cannot be turned into
|
||||
// a number of attempts.
|
||||
var (
|
||||
// errAttemptsInvalid signals a max_attempts form value that is not
|
||||
// a whole number of at least 1.
|
||||
errAttemptsInvalid = errors.New(
|
||||
"must be a whole number, at least 1",
|
||||
)
|
||||
|
||||
// errAttemptsTooLarge signals a max_attempts form value that is a
|
||||
// whole number but above maxTargetAttempts. It is distinguished
|
||||
// from errAttemptsInvalid so the message can name the ceiling
|
||||
// instead of implying the input was not a number.
|
||||
errAttemptsTooLarge = errors.New("out of range")
|
||||
)
|
||||
|
||||
// parseMaxAttempts interprets a max_attempts 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, below 1, or above maxTargetAttempts.
|
||||
//
|
||||
// The distinction is the whole point of this function. Returning a
|
||||
// default for input the operator typed but that did not parse would
|
||||
// silently turn retries off on a store-and-forward proxy — and on the
|
||||
// edit path destroy a working configuration over a typo. A default
|
||||
// answers a question that was not asked; it never answers one that
|
||||
// was asked badly.
|
||||
func parseMaxAttempts(raw string, fallback int) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 1 {
|
||||
return 0, errAttemptsInvalid
|
||||
}
|
||||
|
||||
if v > maxTargetAttempts {
|
||||
return 0, errAttemptsTooLarge
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// attemptsErrorMessage returns the message the create and edit forms
|
||||
// show for a rejected max_attempts value. Any error other than
|
||||
// errAttemptsTooLarge falls back to the generic wording, so an
|
||||
// unrecognised parse failure still produces a sensible 400.
|
||||
func attemptsErrorMessage(err error) string {
|
||||
if errors.Is(err, errAttemptsTooLarge) {
|
||||
return errAttemptsTooLarge.Error() +
|
||||
": at most " + strconv.Itoa(maxTargetAttempts) +
|
||||
" attempts"
|
||||
}
|
||||
|
||||
return errAttemptsInvalid.Error()
|
||||
}
|
||||
Reference in New Issue
Block a user