Skip to content

fix: retry transient notification delivery failures - #454

Merged
thegdsks merged 1 commit into
mainfrom
fix/notification-delivery-retry
Sep 14, 2026
Merged

thegdsks merged 1 commit into
mainfrom
fix/notification-delivery-retry

Conversation

@thegdsks

@thegdsks thegdsks commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

Every HTTP-based notification kind (Slack, Discord, Telegram, PagerDuty, Teams, Mattermost, Lark, RocketChat, Webex, Google Chat, Opsgenie, Gotify, Ntfy, Resend, and the generic webhook, for both alert rules and deploy-outcome notifications) funnels through one send path, postJSONWithAuth. It made exactly one attempt: a transient 500 or timeout from the receiver was recorded as a permanent failure with no second chance, indistinguishable from a genuinely broken channel. For a crashloop or deploy-failure alert, that meant a real, time-sensitive notification could be silently lost to a one-off receiver hiccup.

postJSONWithAuth now retries up to 3 times with a short exponential backoff (500ms, 1s) on a transient failure: a transport-level error (DNS, TLS, connection refused, a client-side timeout) or a 5xx/429 response. Any other status (a malformed payload, a bad credential, a 404'd webhook URL) still fails on the first attempt, since retrying an inherently-wrong request only delays surfacing the real, fixable problem. The retry loop also respects context cancellation during its backoff wait rather than blindly sleeping through it.

deploy_notify.go already reuses this exact same postJSONWithAuth/postJSON pair for every one of its own channel kinds, so deploy-outcome notifications get the same retry behavior with no separate change.

Verification

  • New tests: a transient-then-success case (retries exactly until the last, succeeding attempt), a persistent-5xx case (retries exactly notifyMaxAttempts times then fails, not fewer or unbounded), a 4xx case (never retried, fails on the first attempt), a transport-error case (connection refused against a closed server), and a context-cancellation-during-backoff case (stops retrying once ctx is done instead of sleeping through it).
  • notifyRetryBaseDelay is a var, shrunk to 1ms via a new TestMain for this package, so the whole suite (including the pre-existing TestNotify_ReceiverErrorStatus_Errors, which now also retries) stays fast.
  • go build ./..., go vet ./..., golangci-lint run clean. Full go test ./... -short (including test/e2e) passes.

What this doesn't do

  • Doesn't cover email: it sends through the control plane's own SMTP client, a different transport with different failure semantics (auth failures shouldn't retry the same way a connection timeout should), not wired into this retry path.
  • Doesn't add exposed delivery-attempt-count or retry-history detail to the notification_deliveries table or the dashboard's delivery history view: a delivery row still just records the final outcome, the same shape as before, not each individual attempt within it.
  • Doesn't surface a channel's recent failure rate anywhere at a glance (the channel list still only shows enabled/disabled): an operator still has to open a channel's own delivery history to notice it's been failing. Left as a separate follow-up.

Summary by CodeRabbit

  • New Features

    • HTTP-based notification delivery now automatically retries transient failures, including network errors, rate limits, and server errors.
    • Retries use exponential backoff and stop after three attempts.
  • Bug Fixes

    • Permanent failures, such as invalid requests, credentials, or webhook URLs, fail immediately.
    • Canceled operations stop retrying during the backoff period.
  • Documentation

    • Added documentation describing notification retry behavior and limitations.

Every HTTP-based notification kind (Slack, Discord, Telegram, PagerDuty,
Teams, Mattermost, Lark, RocketChat, Webex, Google Chat, Opsgenie,
Gotify, Ntfy, Resend, and the generic webhook, for both alert rules and
deploy-outcome notifications) funnels through one send path,
postJSONWithAuth. It made exactly one attempt: a transient 500 or
timeout from the receiver was recorded as a permanent failure with no
second chance, indistinguishable from a genuinely broken channel. For a
crashloop or deploy-failure alert, that meant a real, time-sensitive
notification could be silently lost to a one-off receiver hiccup.

postJSONWithAuth now retries up to 3 times with a short exponential
backoff (500ms, 1s) on a transient failure: a transport-level error
(DNS, TLS, connection refused, a client-side timeout) or a 5xx/429
response. Any other status (a malformed payload, a bad credential, a
404'd webhook URL) still fails on the first attempt, since retrying an
inherently-wrong request only delays surfacing the real, fixable
problem. The retry loop also respects context cancellation during its
backoff wait rather than blindly sleeping through it.

deploy_notify.go already reuses this exact same postJSONWithAuth/
postJSON pair for every one of its own channel kinds, so deploy-outcome
notifications get the same retry behavior with no separate change.

What this doesn't do:
- Doesn't cover email: it sends through the control plane's own SMTP
  client, a different transport with different failure semantics (auth
  failures shouldn't retry the same way a connection timeout should),
  not wired into this retry path.
- Doesn't add exposed delivery-attempt-count or retry-history detail to
  the notification_deliveries table or the dashboard's delivery history
  view: a delivery row still just records the final outcome, the same
  shape as before, not each individual attempt within it.
- Doesn't surface a channel's recent failure rate anywhere at a glance
  (the channel list still only shows enabled/disabled): an operator
  still has to open a channel's own delivery history to notice it's
  been failing. Left as a separate follow-up.
@thegdsks
thegdsks merged commit ec36d75 into main Sep 14, 2026
8 checks passed
@github-actions github-actions Bot added size/l 200-499 lines changed type/fix Bug fix area/alerting internal/alerting type/docs Documentation only labels Sep 14, 2026
@thegdsks
thegdsks deleted the fix/notification-delivery-retry branch September 14, 2026 04:26
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 66f280e1-5623-4037-bc44-bd9e51e5a582

📥 Commits

Reviewing files that changed from the base of the PR and between 65d53ce and bb83142.

📒 Files selected for processing (3)
  • docs/observability.md
  • internal/alerting/notify.go
  • internal/alerting/notify_test.go

📝 Walkthrough

Walkthrough

Changes

The notification HTTP path now retries transient transport, 5xx, and 429 failures up to three attempts with exponential backoff. Permanent client errors stop immediately. Tests and observability documentation cover the behavior.

Notification delivery retries

Layer / File(s) Summary
Failure classification and attempt execution
internal/alerting/notify.go
The notifier classifies transport and HTTP failures. Request construction and sending run in postJSONAttempt.
Retry loop and cancellation
internal/alerting/notify.go
postJSONWithAuth retries eligible failures with exponential backoff and respects context cancellation.
Retry validation and observability documentation
internal/alerting/notify_test.go, docs/observability.md
Tests cover retry limits, client errors, transport errors, and cancellation. Documentation describes retry behavior and excludes email delivery.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Notify
  participant postJSONWithAuth
  participant NotificationEndpoint
  Notify->>postJSONWithAuth: deliver notification
  postJSONWithAuth->>NotificationEndpoint: send HTTP request
  NotificationEndpoint-->>postJSONWithAuth: transient failure
  postJSONWithAuth->>postJSONWithAuth: wait with exponential backoff
  postJSONWithAuth->>NotificationEndpoint: retry request
  NotificationEndpoint-->>postJSONWithAuth: success or final failure
  postJSONWithAuth-->>Notify: return result
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/notification-delivery-retry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR appears safe to merge after considering several non-blocking retry-semantics and test-quality improvements.

Findings

  1. P2 Retries Can Duplicate Notifications
  2. P2 Permanent Errors Are Retried
  3. P2 Cancellation Returns Stale Error
  4. P2 Transport Retry Is Unverified

Summary

  • Retries transport failures, HTTP 5xx responses, and HTTP 429 responses up to three times.
  • Stops retrying permanent HTTP errors and observes context cancellation during backoff.
  • Adds coverage for successful retries, exhausted retries, permanent errors, transport failures, and cancellation.
  • The implementation should better distinguish deterministic request errors, preserve cancellation semantics, and account for duplicate-delivery behavior.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Build JSON payload] --> B[POST notification]
  B --> C{Result}
  C -->|2xx| D[Success]
  C -->|Non-429 4xx| E[Return receiver error]
  C -->|5xx / 429 / transport error| F{Attempts remaining?}
  F -->|No| G[Return final error]
  F -->|Yes| H[Wait with exponential backoff]
  H --> I{Context canceled?}
  I -->|Yes| J[Return cancellation]
  I -->|No| B
Loading

Reviews (1) · Last reviewed commit: "fix: retry transient notification delive..."

Comment on lines +702 to +703
for attempt := 1; attempt <= notifyMaxAttempts; attempt++ {
lastErr = postJSONAttempt(ctx, client, url, body, authHeader)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Retries Can Duplicate Notifications

If a receiver processes a POST but its response is lost or the client times out, this loop sends the same payload again. Because the generic webhook payload has no idempotency identifier, receivers that do not deduplicate requests may emit the same alert multiple times. This creates at-least-once delivery behavior that should be accounted for, ideally with receiver-supported deduplication where available.

Comment on lines +672 to +677
func isRetryableNotifyError(err error) bool {
var httpErr *notifyHTTPError
if errors.As(err, &httpErr) {
return httpErr.statusCode >= http.StatusInternalServerError || httpErr.statusCode == http.StatusTooManyRequests
}
return true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Permanent Errors Are Retried

Every error other than notifyHTTPError is classified as retryable, including deterministic request-construction and client-configuration errors. A user-supplied malformed or unsupported URL therefore incurs all three attempts and 1.5 seconds of backoff even though it cannot succeed, delaying later rules in the sequential notification pass. Please distinguish permanent request errors from retryable network failures.

Comment on lines +712 to +714
case <-ctx.Done():
timer.Stop()
return lastErr

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cancellation Returns Stale Error

When the context is canceled during backoff, this branch returns the error from the preceding delivery attempt rather than ctx.Err(). For example, a deadline exceeded while waiting after a 503 is reported as another receiver failure, obscuring that delivery stopped because the caller canceled the operation.

Comment on lines +203 to +217
func TestNotify_TransportError_Retries(t *testing.T) {
// A server that's already closed: every request fails at the
// transport level (connection refused), never even reaching an HTTP
// status, the other retryable case isRetryableNotifyError covers.
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
url := srv.URL
srv.Close()

r := Rule{ID: "r1", NotifyURL: url}
notifier := NewNotifier(nil, nil, r)

if err := notifier.Notify(context.Background(), Event{Rule: r}); err == nil {
t.Fatal("Notify() error = nil, want an error against a closed server")
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Transport Retry Is Unverified

This test only checks that sending to a closed server returns an error, so it would still pass if transport failures were attempted exactly once. Add an observable attempt count, such as through a counting RoundTripper, so the test detects regressions that remove transport-error retries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/alerting internal/alerting size/l 200-499 lines changed type/docs Documentation only type/fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant