package handlers import ( "errors" "net/http" "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( "retries must be a whole number of attempts", ) // 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") ) // 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) + " retries" } return errRetriesInvalid.Error() + ", or 0 for fire-and-forget" } // targetMaxRetries reads and validates max_retries from a target form // submission, answering the request with a 400 and reporting false // when the value is set but invalid. // // Both the create and the edit path go through here, so the two // cannot come to disagree about what a valid retry count is. The // wording matches the timeout control on the same submission. func targetMaxRetries( w http.ResponseWriter, r *http.Request, fallback int, ) (int, bool) { retries, err := parseMaxRetries( r.PostFormValue("max_retries"), fallback, ) if err != nil { http.Error( w, "Invalid max retries: "+retriesErrorMessage(err), http.StatusBadRequest, ) return 0, false } return retries, true }