The webhook page's targets section lists only its targets until Add is clicked. Add shows a choice of target type with Next; Next shows only that type's fields, with Save and Cancel. The `database` and `log` types have no URL field, and the `slack` form gains max retries. A refused target now shows the webhook page again with the form open on its type, the values entered and the reason, instead of a bare text page. Target validation returns that message rather than writing the response; newTarget validates a whole new target for reuse by the new-webhook page. The edit page still answers a refusal in plain text. 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
|
|
}
|