Found while implementing #202, with a second defect added from its review.
Two problems in the same helper, parseNonNegativeInt (internal/handlers/source_management.go:1183-1194), which HandleTargetCreate uses for max_retries:
1. No ceiling. Any non-negative value is accepted. parseRetentionDays in the same file shows the intended shape: validate against a stated ceiling and answer 400 naming it. A typo'd extra zero gives MaxRetries of 100000 and the engine will genuinely record that many delivery_results rows, each of which the event log then loads and renders.
2. Unparseable input silently returns 0. This is the silent-fallback pattern the owner rejected on #78 — a set-but-invalid value must fail loudly, never degrade into a default the operator did not ask for. Here a garbage max_retries quietly means "never retry", which is the opposite of what someone typing a number into that field intends, and nothing tells them.
It is operator-chosen rather than sender-chosen, so it is a robustness bug rather than a 1.0 blocker — an operator can only shoot themselves.
Definition of done:
max_retries is validated at creation and edit against a stated ceiling, rejected with 400 naming the ceiling
unparseable input is rejected with 400, never coerced to 0; audit the other callers of parseNonNegativeInt for the same coercion and fix or justify each
the same validation applies on the target edit path added by #127, so the two cannot disagree
existing stored values above the ceiling still render and still deliver; this is input validation, not a migration
tests cover a form post above the ceiling and an unparseable one, each rejected with the reason named
Found while implementing https://git.eeqj.de/sneak/webhooker/issues/202, with a second defect added from its review.
Two problems in the same helper, `parseNonNegativeInt` (`internal/handlers/source_management.go:1183-1194`), which `HandleTargetCreate` uses for `max_retries`:
**1. No ceiling.** Any non-negative value is accepted. `parseRetentionDays` in the same file shows the intended shape: validate against a stated ceiling and answer 400 naming it. A typo'd extra zero gives `MaxRetries` of 100000 and the engine will genuinely record that many `delivery_results` rows, each of which the event log then loads and renders.
**2. Unparseable input silently returns 0.** This is the silent-fallback pattern the owner rejected on https://git.eeqj.de/sneak/webhooker/pulls/78 — a set-but-invalid value must fail loudly, never degrade into a default the operator did not ask for. Here a garbage `max_retries` quietly means "never retry", which is the opposite of what someone typing a number into that field intends, and nothing tells them.
It is operator-chosen rather than sender-chosen, so it is a robustness bug rather than a 1.0 blocker — an operator can only shoot themselves.
Definition of done:
- `max_retries` is validated at creation and edit against a stated ceiling, rejected with 400 naming the ceiling
- unparseable input is rejected with 400, never coerced to 0; audit the other callers of `parseNonNegativeInt` for the same coercion and fix or justify each
- the same validation applies on the target edit path added by https://git.eeqj.de/sneak/webhooker/issues/127, so the two cannot disagree
- existing stored values above the ceiling still render and still deliver; this is input validation, not a migration
- tests cover a form post above the ceiling and an unparseable one, each rejected with the reason named
clawbot
changed title from max_retries is unbounded at target creation, so one target can queue arbitrarily many delivery attempts to max_retries is unbounded at target creation, and unparseable input silently becomes 02026-08-20 06:38:28 +02:00
Modelled on parseRetentionDays / delivery.ParseTargetTimeout, which already get this right in the same form.
New parseMaxRetries(raw string, fallback int) (int, error) in internal/handlers: empty means ABSENT and yields fallback; anything unparseable or negative is an error; anything above the ceiling is a distinct error. No path returns a substituted default for a set-but-invalid value.
Ceiling maxTargetRetries = 20. Both templates/source_detail.html and templates/target_edit.html already declare max="20" on the input; the server simply never enforced what the UI advertises. Backoff is 2^(n-1) seconds, so attempt 20 is already ~6 days out, and each attempt writes a delivery_results row the event log then loads.
Wired into BOTH paths from one helper: processTargetCreate (fallback 0) and applyTargetEdit (fallback = stored MaxRetries, kept inside the existing PostForm.Has guard so a form that does not render the field still does not touch retries). They cannot disagree because they call the same function.
400 wording matches the timeout control on the same submission: Invalid max retries: ..., naming the ceiling when out of range.
Audit of parseNonNegativeInt: its other two callers (finishResubmit, finishReplay) parse the log page number for a redirect, not configuration. Coercion is correct there and already matches parsePage on the GET side. I will delete parseNonNegativeInt and give those callers a pageOrFirst helper shared with parsePage, so no silently-coercing general-purpose int parser is left lying around for a config field to reach for.
Validation only. Stored values above the ceiling keep rendering and delivering; no migration, no clamping of existing rows.
Tests on both create and edit: above-ceiling rejected with the ceiling named, abc / 2.7 / -5 / a 20-digit number rejected rather than coerced to 0, a valid value stored, an absent field still taking its default, and the edit case proving a working max_retries=2 survives a rejected submission.
Plan.
Modelled on `parseRetentionDays` / `delivery.ParseTargetTimeout`, which already get this right in the same form.
- New `parseMaxRetries(raw string, fallback int) (int, error)` in `internal/handlers`: empty means ABSENT and yields `fallback`; anything unparseable or negative is an error; anything above the ceiling is a distinct error. No path returns a substituted default for a set-but-invalid value.
- Ceiling `maxTargetRetries = 20`. Both `templates/source_detail.html` and `templates/target_edit.html` already declare `max="20"` on the input; the server simply never enforced what the UI advertises. Backoff is `2^(n-1)` seconds, so attempt 20 is already ~6 days out, and each attempt writes a `delivery_results` row the event log then loads.
- Wired into BOTH paths from one helper: `processTargetCreate` (fallback 0) and `applyTargetEdit` (fallback = stored `MaxRetries`, kept inside the existing `PostForm.Has` guard so a form that does not render the field still does not touch retries). They cannot disagree because they call the same function.
- 400 wording matches the `timeout` control on the same submission: `Invalid max retries: ...`, naming the ceiling when out of range.
- Audit of `parseNonNegativeInt`: its other two callers (`finishResubmit`, `finishReplay`) parse the log page number for a redirect, not configuration. Coercion is correct there and already matches `parsePage` on the GET side. I will delete `parseNonNegativeInt` and give those callers a `pageOrFirst` helper shared with `parsePage`, so no silently-coercing general-purpose int parser is left lying around for a config field to reach for.
- Validation only. Stored values above the ceiling keep rendering and delivering; no migration, no clamping of existing rows.
Tests on both create and edit: above-ceiling rejected with the ceiling named, `abc` / `2.7` / `-5` / a 20-digit number rejected rather than coerced to 0, a valid value stored, an absent field still taking its default, and the edit case proving a working `max_retries=2` survives a rejected submission.
parseNonNegativeInt is removed. max_retries on both the create and the edit form now goes through one parseMaxRetries: empty or omitted takes the caller's fallback (0 at creation, the stored count on edit), and anything else that is not a whole number at most 20 is a 400 that names the reason. Ceiling is 20 because both templates have always declared max="20" on the input and the server never enforced it; backoff is 2^(n-1) seconds, so attempt 20 is already about six days out.
The two other callers (finishResubmit, finishReplay) read the log page number for a post-action redirect, where falling back to page 1 is correct and already matches parsePage on the GET side. They use a pageOrFirst named for that, so no general silently-coercing parser is left in the package. Full disposition table in the PR.
Verified: make check green with GOFLAGS=-count=1 — 570 tests, 0 failures, lint 0 issues in Docker. Ten new tests cover both paths for above-ceiling, abc, 2.7, -5, a 20-digit number, valid values including the ceiling itself, and an absent field still taking its default; one test submits every case to both paths and asserts the verdicts match.
On a running instance, the reported edit case — a target delivering with max_retries=2, re-saved with abc — now answers HTTP 400 Invalid max retries: retries must be a whole number of attempts, or 0 for fire-and-forget, and the stored value is still 2 with the submission's name change not written either. A row set to 100000 directly in the database still renders on both pages and is not clamped.
Done in https://git.eeqj.de/sneak/webhooker/pulls/259 (base `next`).
`parseNonNegativeInt` is removed. `max_retries` on both the create and the edit form now goes through one `parseMaxRetries`: empty or omitted takes the caller's fallback (0 at creation, the stored count on edit), and anything else that is not a whole number at most 20 is a 400 that names the reason. Ceiling is 20 because both templates have always declared `max="20"` on the input and the server never enforced it; backoff is `2^(n-1)` seconds, so attempt 20 is already about six days out.
The two other callers (`finishResubmit`, `finishReplay`) read the log page number for a post-action redirect, where falling back to page 1 is correct and already matches `parsePage` on the GET side. They use a `pageOrFirst` named for that, so no general silently-coercing parser is left in the package. Full disposition table in the PR.
Verified: `make check` green with `GOFLAGS=-count=1` — 570 tests, 0 failures, lint 0 issues in Docker. Ten new tests cover both paths for above-ceiling, `abc`, `2.7`, `-5`, a 20-digit number, valid values including the ceiling itself, and an absent field still taking its default; one test submits every case to both paths and asserts the verdicts match.
On a running instance, the reported edit case — a target delivering with `max_retries=2`, re-saved with `abc` — now answers `HTTP 400 Invalid max retries: retries must be a whole number of attempts, or 0 for fire-and-forget`, and the stored value is still 2 with the submission's name change not written either. A row set to 100000 directly in the database still renders on both pages and is not clamped.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found while implementing #202, with a second defect added from its review.
Two problems in the same helper,
parseNonNegativeInt(internal/handlers/source_management.go:1183-1194), whichHandleTargetCreateuses formax_retries:1. No ceiling. Any non-negative value is accepted.
parseRetentionDaysin the same file shows the intended shape: validate against a stated ceiling and answer 400 naming it. A typo'd extra zero givesMaxRetriesof 100000 and the engine will genuinely record that manydelivery_resultsrows, each of which the event log then loads and renders.2. Unparseable input silently returns 0. This is the silent-fallback pattern the owner rejected on #78 — a set-but-invalid value must fail loudly, never degrade into a default the operator did not ask for. Here a garbage
max_retriesquietly means "never retry", which is the opposite of what someone typing a number into that field intends, and nothing tells them.It is operator-chosen rather than sender-chosen, so it is a robustness bug rather than a 1.0 blocker — an operator can only shoot themselves.
Definition of done:
max_retriesis validated at creation and edit against a stated ceiling, rejected with 400 naming the ceilingparseNonNegativeIntfor the same coercion and fix or justify eachmax_retries is unbounded at target creation, so one target can queue arbitrarily many delivery attemptsto max_retries is unbounded at target creation, and unparseable input silently becomes 0Plan.
Modelled on
parseRetentionDays/delivery.ParseTargetTimeout, which already get this right in the same form.parseMaxRetries(raw string, fallback int) (int, error)ininternal/handlers: empty means ABSENT and yieldsfallback; anything unparseable or negative is an error; anything above the ceiling is a distinct error. No path returns a substituted default for a set-but-invalid value.maxTargetRetries = 20. Bothtemplates/source_detail.htmlandtemplates/target_edit.htmlalready declaremax="20"on the input; the server simply never enforced what the UI advertises. Backoff is2^(n-1)seconds, so attempt 20 is already ~6 days out, and each attempt writes adelivery_resultsrow the event log then loads.processTargetCreate(fallback 0) andapplyTargetEdit(fallback = storedMaxRetries, kept inside the existingPostForm.Hasguard so a form that does not render the field still does not touch retries). They cannot disagree because they call the same function.timeoutcontrol on the same submission:Invalid max retries: ..., naming the ceiling when out of range.parseNonNegativeInt: its other two callers (finishResubmit,finishReplay) parse the log page number for a redirect, not configuration. Coercion is correct there and already matchesparsePageon the GET side. I will deleteparseNonNegativeIntand give those callers apageOrFirsthelper shared withparsePage, so no silently-coercing general-purpose int parser is left lying around for a config field to reach for.Tests on both create and edit: above-ceiling rejected with the ceiling named,
abc/2.7/-5/ a 20-digit number rejected rather than coerced to 0, a valid value stored, an absent field still taking its default, and the edit case proving a workingmax_retries=2survives a rejected submission.Done in #259 (base
next).parseNonNegativeIntis removed.max_retrieson both the create and the edit form now goes through oneparseMaxRetries: empty or omitted takes the caller's fallback (0 at creation, the stored count on edit), and anything else that is not a whole number at most 20 is a 400 that names the reason. Ceiling is 20 because both templates have always declaredmax="20"on the input and the server never enforced it; backoff is2^(n-1)seconds, so attempt 20 is already about six days out.The two other callers (
finishResubmit,finishReplay) read the log page number for a post-action redirect, where falling back to page 1 is correct and already matchesparsePageon the GET side. They use apageOrFirstnamed for that, so no general silently-coercing parser is left in the package. Full disposition table in the PR.Verified:
make checkgreen withGOFLAGS=-count=1— 570 tests, 0 failures, lint 0 issues in Docker. Ten new tests cover both paths for above-ceiling,abc,2.7,-5, a 20-digit number, valid values including the ceiling itself, and an absent field still taking its default; one test submits every case to both paths and asserts the verdicts match.On a running instance, the reported edit case — a target delivering with
max_retries=2, re-saved withabc— now answersHTTP 400 Invalid max retries: retries must be a whole number of attempts, or 0 for fire-and-forget, and the stored value is still 2 with the submission's name change not written either. A row set to 100000 directly in the database still renders on both pages and is not clamped.