Isolate webhook delivery per destination and skip futile retries - #91
Merged
Conversation
Two failure modes made webhook delivery degrade far worse than it needed to when a single endpoint went bad: - deliver treated every non-2xx alike, so a permanent 400/404/410 burned the full retry/backoff budget re-POSTing an identical body that could never be accepted. - A single serial worker drained the shared queue, so that wasted budget (and any slow endpoint) head-of-line-blocked every other webhook and every queued event. Give each webhook its own bounded queue, worker goroutine, and rate-limit state, so one dead destination can only delay and drop its own events. The severity filter moves to Notify, where it is applied inline while fanning out; it is pure and cheap, so it costs the collector nothing. Classify delivery errors: transport failures and 5xx stay retryable, as do 408 and 429 which explicitly invite a retry, while every other 4xx is a permanent rejection that ends the attempt immediately. Closes #63
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Pull Request
📖 Description
Fix (robustness/performance), no breaking change.
Two failure modes in
internal/alert/notify.gocombined to make webhook delivery degrade far worse than necessary when a single endpoint went bad:delivertreated every non-2xx alike, so a permanent400/404/410— a revoked webhook URL, a payload the endpoint rejects — was re-POSTed up tonotify_max_retriesmore times with exponential backoff, even though an identical request can never be accepted.(retries+1) × timeout + backoffsper event.This PR addresses both halves of the suggested fix in the issue:
defaultNotifyQueueSize= 256, unchanged per destination), its own worker goroutine, and its own rate-limit state (webhookWorker). A webhook pointing at a dead host can now only delay — and only ever drop — its own events. The severity filter (min_level) moves intoNotify, where it is applied inline while fanning the event out; it is pure and cheap, so it costs the collector nothing. Because each worker owns itslastSentmap exclusively, the "no lock needed" property is preserved.postnow returns a typed*statusError, andretryabledecides whether another attempt is worth making: transport failures (DNS, refused connections, timeouts) and 5xx stay retryable, as do408 Request Timeoutand429 Too Many Requests, which explicitly invite a retry. Every other 4xx ends the attempt immediately with a distinctalert notification rejected, not retryinglog line. 3xx (which the client would normally have followed) is left retryable, i.e. unchanged behavior.Ordering within a webhook, the queue-full drop valve, the cleared-bypasses-rate-limit rule, and the "failed delivery doesn't stamp the rate limiter" fix from #62 are all preserved.
🎫 Issues
Closes #63
👩💻 Reviewer Notes
rateLimitKeyis nowmetric\x00resource) because the state lives on the per-webhook worker instead of one shared map.TestNotifier_RateLimitStateIsPerWebhookpins that two webhooks no longer share a limiter.dispatch(worker side) toNotify(caller side) means a filtered-out event never occupies queue space — a small side benefit, but it does meanNotifynow does slightly more work on the collector's goroutine.eventReachesis a pure comparison of two levels, so this is a few nanoseconds per (event × webhook)./proc,/sys, or Pi-specific code paths. It is fully exercised byhttptestservers.📑 Test Plan
Added to
internal/alert/notify_test.go, followingdocs/TESTS.mdconventions (Test<Subject>_<Scenario>,httptestfixtures, no real/proc//sysaccess):TestNotifier_DoesNotRetryPermanentClientError— a 404 endpoint withnotify_max_retries: 3must be hit exactly once. Verified failing against the pre-fix implementation (got 4).TestNotifier_SlowWebhookDoesNotBlockOthers— one webhook hangs until released, a second must still receive its delivery. Verified failing against the pre-fix implementation (timed out waiting for delivery).TestNotifier_RetriesRetryableClientError— 429 must still consume the full retry budget (1 + 2 retries), guarding against over-broad 4xx suppression.TestNotifier_RateLimitStateIsPerWebhook— the same event reaches both destinations despite a 60 snotify_min_interval_seconds.TestRetryable— table test over the classification: transport error, 400/404/410/403-wrapped, 408, 429, 500, 503, 304.Existing notifier tests (payload, template, content type, min-level filtering, retry-then-give-up, rate limiting, cleared bypass, #62 regression) pass unchanged.
Full suite locally:
go build ./...— cleango vet ./...— cleango test ./... -race -cover— all packages pass;internal/alertcoverage 93.1%golangci-lint run— 0 issues✅ Checklist
General
go test ./... -race -coverpasses locally).go vet ./...andgolangci-lint runare clean.ARCHITECTURE.mdif this changes a documented design decision.REST API / configuration / packaging
Not applicable — no REST API, configuration, or packaging change. Existing
notify_max_retries,notify_retry_backoff_seconds, andnotify_min_interval_secondskeep their meaning; the retry budget is simply no longer spent on responses that cannot change.⏭ Next Steps
None required. A possible follow-up (out of scope here, and not requested by the issue) would be honoring a
Retry-Afterheader on 429/503 instead of the fixed exponential backoff.