check / check (push) Waiting to run
The targets section of the webhook page showed its add form open with every field, a URL included for types that use none. It now lists only its targets until "+ Add" is clicked; "+ Add" shows a choice of target type with Next and Cancel on one row, and Next shows the name and only that type's fields. The database and log types show no URL field and the server stores none for them; the slack form gains its retry field. A refused target brings the page back with the form open on its type, the values entered and the reason, and Cancel empties it. An encoding failure stays a logged 500. Target validation returns its message, so the new-webhook page can reuse it. Model: opus-5-5
121 lines
3.8 KiB
Go
121 lines
3.8 KiB
Go
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 edit
|
|
// submission, answering the request with a 400 and reporting false
|
|
// when the value is set but invalid.
|
|
//
|
|
// It and the create path (newTarget) both use parseMaxRetries and
|
|
// retriesErrorMessage, 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
|
|
}
|