Skip to content

fix(server): bound alert webhook/SMTP delivery timeouts (JEF-497)#126

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-497-alertnotify-p99-115s-webhooksmtp-sinks-have-no-timeout-bound
Jul 25, 2026
Merged

fix(server): bound alert webhook/SMTP delivery timeouts (JEF-497)#126
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-497-alertnotify-p99-115s-webhooksmtp-sinks-have-no-timeout-bound

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

JEF-497 — alert.notify p99 ~11.5s: webhook/SMTP sinks have no timeout

Symptom

alert.notify spans cluster at ~11.5s (11,494 / 11,604 / 11,750ms) with error_count=0 — slow, not failing: a fixed connect/handshake stall in the alert delivery path (not ingest).

Root cause

notify() delivered to two sinks with no timeout:

  • Webhookreqwest::Client::new() has neither a request nor a connect timeout, so a slow/unresponsive receiver blocked send() up to the OS TCP timeout.
  • Email — the lettre AsyncSmtpTransport was built without .timeout(...), so a slow SMTP relay handshake stalled just as long.

notify() is awaited inside the eval tick (run → evaluate → notify), so a stalled sink delayed the whole tick and backed up other rules' notifications.

Fix

  • New shared build_webhook_client() helper builds the reqwest client once with explicit bounds — 10s total request / 5s connect — reused by both run and evaluate_once so both delivery paths get identical config. A failed build() logs and disables the webhook rather than panicking, matching how a bad SMTP config disables email.
  • Mailer::build now sets .timeout(Some(Duration::from_secs(10))) on the SMTP transport.
  • Values chosen short enough that a dead sink can't stall the loop, long enough for a legitimately slow-but-alive receiver. One-line comments explain each.

Re-architecting the loop (spawning notify off the tick / a delivery queue) is a noted follow-up and explicitly out of scope here — the timeouts are the fix.

Test

notify_gives_up_on_a_hung_webhook_within_timeout: a local tokio::net::TcpListener that accepts but never responds (the exact hung-receiver case). notify() gives up in ~10s (measured 10.01s), asserted < 15s — bounded by the request timeout, not the multi-second OS TCP timeout. Deterministic, no real-network dependency.

Gates

cargo fmt / cargo check / cargo clippy --all-targets -- -D warnings / cargo test --locked — all green (57 tests incl. the new one).

🤖 Generated with Claude Code

https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo

alert.notify spans clustered at ~11.5s (11,494/11,604/11,750ms, error_count=0)
— slow, not failing — a fixed connect/handshake stall in the delivery path.

Both notify() sinks were unbounded:
- Webhook used reqwest::Client::new(), which has no request AND no connect
  timeout, so a slow/unresponsive receiver blocked send() up to the OS TCP
  timeout.
- The lettre AsyncSmtpTransport was built without .timeout(...), so a slow
  SMTP relay handshake stalled just as long.

Because notify() is awaited inside the eval tick (run → evaluate → notify), a
stalled sink delayed the whole tick and backed up other rules' notifications.

Fix: build the webhook client once with explicit bounds (10s request / 5s
connect) via a shared build_webhook_client() helper so run and evaluate_once
get identical config; a failed build logs and disables the webhook rather than
panicking, matching how a bad SMTP config disables email. Give the SMTP
transport .timeout(Some(10s)) in Mailer::build. Values are short enough a dead
sink can't stall the loop, long enough for a legitimately slow-but-alive
receiver.

Test: a local TcpListener that accepts but never responds (the exact hung-
receiver case) makes notify() give up in ~10s (measured 10.01s), asserted
< 15s — bounded by the request timeout, not the multi-second OS TCP timeout.

Re-architecting the loop (spawning notify off the tick / a delivery queue) is
a noted follow-up and out of scope here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo
@thejefflarson
thejefflarson merged commit 19da1d6 into main Jul 25, 2026
4 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-497-alertnotify-p99-115s-webhooksmtp-sinks-have-no-timeout-bound branch July 25, 2026 23:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant