Skip to content

Commit 56913c1

Browse files
committed
feat(webhooks): retry delivery, with backoff and surface failures
- Refactor `send_webhook_notification` to retry failures with exponential backoff, add a request timeout, and raise `WebhookDeliveryError` on terminal failure instead of accepting it. - Results are persisted so a delivery failure can be surfaced without losing the result. Closes #180
1 parent fa1ef14 commit 56913c1

3 files changed

Lines changed: 129 additions & 20 deletions

File tree

app/utils/webhook_utils.py

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,62 @@
1-
"""Utility methods for sending webhook notifications."""
2-
3-
# Author: Alexander Hambley
4-
# License: MIT
5-
# Copyright (c) 2025 eScience Lab, The University of Manchester
1+
"""Webhook delivery with bounded retries and backoff."""
62

73
import logging
8-
import requests
4+
import time
95

10-
from typing import Any
6+
from typing import Any, Callable
7+
8+
import requests
119

1210
logger = logging.getLogger(__name__)
1311

12+
DEFAULT_TIMEOUT = 10
1413

15-
def send_webhook_notification(url: str, data: Any) -> None:
16-
"""
17-
Sends a POST request to the specified webhook URL with the given data.
1814

19-
:param url: The URL to send the webhook notification to.
20-
:param data: The data to send in the POST request.
21-
:raises requests.RequestException: If an error occurs when sending the notification.
22-
"""
15+
class WebhookDeliveryError(Exception):
16+
"""Raised when a webhook could not be delivered after all retries."""
17+
2318

24-
try:
25-
response = requests.post(url, json=data)
26-
response.raise_for_status()
27-
logging.info(f"Webhook notification sent successfully to {url}")
28-
except requests.RequestException as e:
29-
logging.error(f"Failed to send webhook notification: {e}")
19+
def send_webhook_notification(
20+
url: str,
21+
data: Any,
22+
max_attempts: int = 3,
23+
base_delay: float = 0.5,
24+
sleep: Callable[[float], None] = time.sleep,
25+
) -> None:
26+
"""
27+
POST ``data`` to ``url`` as JSON, retrying transient failures.
28+
29+
Retries up to ``max_attempts`` times with exponential backoff. On final
30+
failure it raises :class:`WebhookDeliveryError` rather than swallowing the
31+
error, so the caller can surface it.
32+
33+
:param url: The webhook URL to POST to.
34+
:param data: JSON-serialisable payload.
35+
:param max_attempts: Total number of attempts before giving up.
36+
:param base_delay: Base backoff delay in seconds (doubled each retry).
37+
:param sleep: Sleep function (injectable for testing).
38+
:raises WebhookDeliveryError: If delivery fails after ``max_attempts``.
39+
"""
40+
last_error = None
41+
42+
for attempt in range(1, max_attempts + 1):
43+
try:
44+
response = requests.post(url, json=data, timeout=DEFAULT_TIMEOUT)
45+
response.raise_for_status()
46+
logger.info("Webhook delivered to %s (attempt %d)", url, attempt)
47+
return
48+
except requests.RequestException as error:
49+
last_error = error
50+
logger.warning(
51+
"Webhook attempt %d/%d to %s failed: %s",
52+
attempt,
53+
max_attempts,
54+
url,
55+
error,
56+
)
57+
if attempt < max_attempts:
58+
sleep(base_delay * (2 ** (attempt - 1)))
59+
60+
raise WebhookDeliveryError(
61+
f"Failed to deliver webhook to {url} after {max_attempts} attempts: {last_error}"
62+
)

tests/test_validation_tasks.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from app.tasks import validation_tasks
1212
from app.tasks.validation_tasks import run_validation_job
1313
from app.utils.config import Settings
14+
from app.utils.webhook_utils import WebhookDeliveryError
1415
from app.validation.results import ValidationOutcome, ValidationStatus
1516

1617
RUNNER = "app.tasks.validation_tasks.validate_crate_path"
@@ -107,6 +108,23 @@ def get_bytes(self, key):
107108
run_validation_job(storage, "foo", _settings(), created_at="t")
108109

109110

111+
def test_webhook_failure_surfaces_but_result_is_already_persisted(storage):
112+
"""A terminal webhook failure propagates, yet the outcome was persisted first."""
113+
storage.put_bytes("crates/foo.zip", b"PK")
114+
115+
with mock.patch(RUNNER) as run, mock.patch(WEBHOOK) as hook:
116+
run.return_value = ValidationOutcome(status=ValidationStatus.VALID, created_at="t")
117+
hook.side_effect = WebhookDeliveryError("gave up")
118+
119+
with pytest.raises(WebhookDeliveryError):
120+
run_validation_job(
121+
storage, "foo", _settings(), webhook_url="https://hook", created_at="t"
122+
)
123+
124+
# Persisted before the webhook was attempted, so GET still works.
125+
assert _stored_outcome(storage, "foo")["status"] == "valid"
126+
127+
110128
def test_created_at_is_persisted(storage):
111129
storage.put_bytes("crates/foo.zip", b"PK")
112130
with mock.patch(RUNNER) as run, mock.patch(WEBHOOK):

tests/test_webhooks.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Tests for webhook delivery with retry/backoff."""
2+
3+
from unittest import mock
4+
5+
import pytest
6+
import requests
7+
8+
from app.utils import webhook_utils
9+
from app.utils.webhook_utils import send_webhook_notification, WebhookDeliveryError
10+
11+
12+
def _ok_response():
13+
response = mock.Mock()
14+
response.raise_for_status.return_value = None
15+
return response
16+
17+
18+
def test_successful_delivery_posts_once():
19+
with mock.patch.object(webhook_utils.requests, "post", return_value=_ok_response()) as post:
20+
send_webhook_notification("https://hook", {"status": "valid"}, sleep=lambda _: None)
21+
post.assert_called_once()
22+
# The payload is sent as JSON and a timeout is set (no unbounded hang).
23+
assert post.call_args.kwargs["json"] == {"status": "valid"}
24+
assert "timeout" in post.call_args.kwargs
25+
26+
27+
def test_retries_then_succeeds():
28+
flaky = [requests.ConnectionError("boom"), requests.ConnectionError("boom"), _ok_response()]
29+
sleeps = []
30+
with mock.patch.object(webhook_utils.requests, "post", side_effect=flaky) as post:
31+
send_webhook_notification(
32+
"https://hook", {"x": 1}, max_attempts=3, sleep=sleeps.append
33+
)
34+
assert post.call_count == 3
35+
assert len(sleeps) == 2 # slept between the three attempts
36+
37+
38+
def test_terminal_failure_raises_after_exhausting_attempts():
39+
with mock.patch.object(
40+
webhook_utils.requests, "post", side_effect=requests.ConnectionError("down")
41+
) as post:
42+
with pytest.raises(WebhookDeliveryError) as exc_info:
43+
send_webhook_notification(
44+
"https://hook", {"x": 1}, max_attempts=3, sleep=lambda _: None
45+
)
46+
assert post.call_count == 3
47+
assert "https://hook" in str(exc_info.value)
48+
49+
50+
def test_http_error_status_is_retried():
51+
bad = mock.Mock()
52+
bad.raise_for_status.side_effect = requests.HTTPError("500")
53+
with mock.patch.object(webhook_utils.requests, "post", return_value=bad) as post:
54+
with pytest.raises(WebhookDeliveryError):
55+
send_webhook_notification(
56+
"https://hook", {"x": 1}, max_attempts=2, sleep=lambda _: None
57+
)
58+
assert post.call_count == 2

0 commit comments

Comments
 (0)