Skip to content

Commit b0a5bb3

Browse files
jmlwebHustle
andauthored
refactor(alerter): replace requests with urllib.request
- Remove requests dependency from alerter.py (stdlib-first principle) - Replace requests.post() with urllib.request.urlopen() in: - _send_webhook() - _send_latency_webhook() - test_webhooks() - Replace requests.RequestException with (urllib.error.URLError, OSError) - Update tests to mock urllib.request.urlopen instead of requests.post - Saves ~2-3MB RAM on Pi 1B+ (identified in BAD_PRACTICES_ANALYSIS.md) All 53 tests passing. Co-authored-by: Hustle <hustle@hustle.local>
1 parent dd020e9 commit b0a5bb3

2 files changed

Lines changed: 78 additions & 64 deletions

File tree

tests/test_alerter.py

Lines changed: 51 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the webhook alerter module."""
22

3+
import json
34
from datetime import UTC, datetime
45
from unittest.mock import MagicMock, Mock, patch
56

@@ -217,29 +218,30 @@ def test_build_payload_up_event(self, alerter: Alerter, check_result_up: CheckRe
217218
assert payload["status"]["success"] is True
218219
assert payload["previous_status"] == "down"
219220

220-
@patch("webstatuspi.alerter.requests.post")
221-
def test_send_webhook_success(self, mock_post: Mock, alerter: Alerter, check_result_down: CheckResult) -> None:
221+
@patch("webstatuspi.alerter.urllib.request.urlopen")
222+
def test_send_webhook_success(self, mock_urlopen: Mock, alerter: Alerter, check_result_down: CheckResult) -> None:
222223
"""Test successful webhook delivery."""
223-
mock_response = MagicMock()
224-
mock_response.raise_for_status.return_value = None
225-
mock_post.return_value = mock_response
224+
mock_cm = MagicMock()
225+
mock_urlopen.return_value.__enter__ = Mock(return_value=mock_cm)
226+
mock_urlopen.return_value.__exit__ = Mock(return_value=False)
226227

227228
webhook = alerter._config.webhooks[0]
228229
alerter._send_webhook(webhook, check_result_down)
229230

230-
mock_post.assert_called_once()
231-
args, kwargs = mock_post.call_args
232-
assert args[0] == "https://example.com/webhook"
233-
assert kwargs["timeout"] == 10
234-
assert isinstance(kwargs["json"], dict)
235-
assert "event" in kwargs["json"]
231+
mock_urlopen.assert_called_once()
232+
call_args = mock_urlopen.call_args
233+
req = call_args[0][0]
234+
assert req.full_url == "https://example.com/webhook"
235+
assert call_args[1]["timeout"] == 10
236+
payload = json.loads(req.data)
237+
assert "event" in payload
236238

237-
@patch("webstatuspi.alerter.requests.post")
238-
def test_send_webhook_retry_on_failure(self, mock_post: Mock, check_result_down: CheckResult) -> None:
239+
@patch("webstatuspi.alerter.urllib.request.urlopen")
240+
def test_send_webhook_retry_on_failure(self, mock_urlopen: Mock, check_result_down: CheckResult) -> None:
239241
"""Test that webhook retries on failure."""
240-
import requests
242+
import urllib.error
241243

242-
mock_post.side_effect = requests.RequestException("Connection error")
244+
mock_urlopen.side_effect = urllib.error.URLError("Connection error")
243245

244246
webhook = WebhookConfig(
245247
url="https://example.com/webhook",
@@ -253,20 +255,21 @@ def test_send_webhook_retry_on_failure(self, mock_post: Mock, check_result_down:
253255
alerter._send_webhook(webhook, check_result_down)
254256

255257
# Should attempt 3 times (initial + 2 retries)
256-
assert mock_post.call_count == 3
258+
assert mock_urlopen.call_count == 3
257259

258-
@patch("webstatuspi.alerter.requests.post")
259-
def test_send_webhook_success_after_retry(self, mock_post: Mock, check_result_down: CheckResult) -> None:
260+
@patch("webstatuspi.alerter.urllib.request.urlopen")
261+
def test_send_webhook_success_after_retry(self, mock_urlopen: Mock, check_result_down: CheckResult) -> None:
260262
"""Test successful delivery after retry."""
261-
mock_response = MagicMock()
262-
mock_response.raise_for_status.return_value = None
263+
import urllib.error
263264

264-
import requests
265+
mock_cm = MagicMock()
266+
mock_cm.__enter__ = Mock(return_value=mock_cm)
267+
mock_cm.__exit__ = Mock(return_value=False)
265268

266269
# Fail first, succeed second
267-
mock_post.side_effect = [
268-
requests.RequestException("Connection error"),
269-
mock_response,
270+
mock_urlopen.side_effect = [
271+
urllib.error.URLError("Connection error"),
272+
mock_cm,
270273
]
271274

272275
webhook = WebhookConfig(
@@ -281,26 +284,26 @@ def test_send_webhook_success_after_retry(self, mock_post: Mock, check_result_do
281284
alerter._send_webhook(webhook, check_result_down)
282285

283286
# Should succeed after retry
284-
assert mock_post.call_count == 2
287+
assert mock_urlopen.call_count == 2
285288

286-
@patch("webstatuspi.alerter.requests.post")
287-
def test_test_webhooks_all_success(self, mock_post: Mock, alerter: Alerter) -> None:
289+
@patch("webstatuspi.alerter.urllib.request.urlopen")
290+
def test_test_webhooks_all_success(self, mock_urlopen: Mock, alerter: Alerter) -> None:
288291
"""Test successful webhook testing."""
289-
mock_response = MagicMock()
290-
mock_response.raise_for_status.return_value = None
291-
mock_post.return_value = mock_response
292+
mock_cm = MagicMock()
293+
mock_urlopen.return_value.__enter__ = Mock(return_value=mock_cm)
294+
mock_urlopen.return_value.__exit__ = Mock(return_value=False)
292295

293296
results = alerter.test_webhooks()
294297

295298
assert results["https://example.com/webhook"] is True
296-
mock_post.assert_called_once()
299+
mock_urlopen.assert_called_once()
297300

298-
@patch("webstatuspi.alerter.requests.post")
299-
def test_test_webhooks_failure(self, mock_post: Mock, alerter: Alerter) -> None:
301+
@patch("webstatuspi.alerter.urllib.request.urlopen")
302+
def test_test_webhooks_failure(self, mock_urlopen: Mock, alerter: Alerter) -> None:
300303
"""Test failed webhook testing."""
301-
import requests
304+
import urllib.error
302305

303-
mock_post.side_effect = requests.RequestException("Connection error")
306+
mock_urlopen.side_effect = urllib.error.URLError("Connection error")
304307

305308
results = alerter.test_webhooks()
306309

@@ -314,10 +317,10 @@ def test_test_webhooks_disabled_webhook(self) -> None:
314317
)
315318
alerter = Alerter(AlertsConfig(webhooks=[webhook]))
316319

317-
with patch("webstatuspi.alerter.requests.post") as mock_post:
320+
with patch("webstatuspi.alerter.urllib.request.urlopen") as mock_urlopen:
318321
results = alerter.test_webhooks()
319322
assert results["https://example.com/webhook"] is False
320-
mock_post.assert_not_called()
323+
mock_urlopen.assert_not_called()
321324

322325
def test_multiple_webhooks(self) -> None:
323326
"""Test alerter with multiple webhooks."""
@@ -476,25 +479,26 @@ def test_latency_counter_reset_on_normal(self, alerter: Alerter, url_config_with
476479
assert alerter._state_tracker.consecutive_slow.get("test_url", 0) == 0
477480
mock_send.assert_not_called() # No alert since we never reached threshold
478481

479-
@patch("webstatuspi.alerter.requests.post")
482+
@patch("webstatuspi.alerter.urllib.request.urlopen")
480483
def test_send_latency_webhook_success(
481-
self, mock_post: Mock, alerter: Alerter, url_config_with_threshold: UrlConfig
484+
self, mock_urlopen: Mock, alerter: Alerter, url_config_with_threshold: UrlConfig
482485
) -> None:
483486
"""Test successful latency webhook delivery."""
484-
mock_response = MagicMock()
485-
mock_response.raise_for_status.return_value = None
486-
mock_post.return_value = mock_response
487+
mock_cm = MagicMock()
488+
mock_urlopen.return_value.__enter__ = Mock(return_value=mock_cm)
489+
mock_urlopen.return_value.__exit__ = Mock(return_value=False)
487490

488491
# Trigger alert
489492
for _ in range(3):
490493
alerter.check_latency_alert(url_config_with_threshold, 1500)
491494

492-
mock_post.assert_called_once()
493-
args, kwargs = mock_post.call_args
494-
assert args[0] == "https://example.com/webhook"
495-
assert kwargs["timeout"] == 10
495+
mock_urlopen.assert_called_once()
496+
call_args = mock_urlopen.call_args
497+
req = call_args[0][0]
498+
assert req.full_url == "https://example.com/webhook"
499+
assert call_args[1]["timeout"] == 10
496500

497-
payload = kwargs["json"]
501+
payload = json.loads(req.data)
498502
assert payload["event"] == "latency_high"
499503
assert payload["url"]["name"] == "test_url"
500504
assert payload["url"]["url"] == "https://example.com"

webstatuspi/alerter.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
"""Webhook and email alert system with state tracking and cooldown management."""
22

3+
import json
34
import logging
45
import smtplib
56
import threading
67
import time
8+
import urllib.error
9+
import urllib.request
710
from dataclasses import dataclass, field
811
from datetime import UTC, datetime
912
from email.mime.multipart import MIMEMultipart
1013
from email.mime.text import MIMEText
1114

12-
import requests
13-
1415
from webstatuspi.config import AlertsConfig, SmtpConfig, UrlConfig, WebhookConfig
1516
from webstatuspi.models import CheckResult
1617
from webstatuspi.security import SSRFError, validate_url_for_ssrf
@@ -191,15 +192,18 @@ def _send_webhook(self, webhook: WebhookConfig, result: CheckResult) -> None:
191192

192193
payload = self._build_payload(result)
193194
retry_count = 0
195+
data = json.dumps(payload).encode()
194196

195197
while retry_count <= self._max_retries:
196198
try:
197-
response = requests.post(
199+
req = urllib.request.Request(
198200
webhook.url,
199-
json=payload,
200-
timeout=10,
201+
data=data,
202+
headers={"Content-Type": "application/json"},
203+
method="POST",
201204
)
202-
response.raise_for_status()
205+
with urllib.request.urlopen(req, timeout=10):
206+
pass
203207

204208
logger.info(
205209
"Webhook sent successfully for %s to %s",
@@ -209,7 +213,7 @@ def _send_webhook(self, webhook: WebhookConfig, result: CheckResult) -> None:
209213
self._state_tracker.last_alert_time[result.url_name] = time.time()
210214
return
211215

212-
except requests.RequestException as e:
216+
except (urllib.error.URLError, OSError) as e:
213217
retry_count += 1
214218
if retry_count <= self._max_retries:
215219
delay = self._retry_delay * (2 ** (retry_count - 1))
@@ -311,14 +315,17 @@ def _send_latency_webhook(
311315
}
312316

313317
retry_count = 0
318+
latency_data = json.dumps(payload).encode()
314319
while retry_count <= self._max_retries:
315320
try:
316-
response = requests.post(
321+
req = urllib.request.Request(
317322
webhook.url,
318-
json=payload,
319-
timeout=10,
323+
data=latency_data,
324+
headers={"Content-Type": "application/json"},
325+
method="POST",
320326
)
321-
response.raise_for_status()
327+
with urllib.request.urlopen(req, timeout=10):
328+
pass
322329

323330
logger.info(
324331
"Latency webhook sent successfully for %s (%s) to %s",
@@ -329,7 +336,7 @@ def _send_latency_webhook(
329336
self._state_tracker.last_alert_time[url_config.name] = time.time()
330337
return
331338

332-
except requests.RequestException as e:
339+
except (urllib.error.URLError, OSError) as e:
333340
retry_count += 1
334341
if retry_count <= self._max_retries:
335342
delay = self._retry_delay * (2 ** (retry_count - 1))
@@ -392,16 +399,19 @@ def test_webhooks(self) -> dict[str, bool]:
392399
}
393400

394401
try:
395-
response = requests.post(
402+
test_data = json.dumps(test_payload).encode()
403+
req = urllib.request.Request(
396404
webhook.url,
397-
json=test_payload,
398-
timeout=10,
405+
data=test_data,
406+
headers={"Content-Type": "application/json"},
407+
method="POST",
399408
)
400-
response.raise_for_status()
409+
with urllib.request.urlopen(req, timeout=10):
410+
pass
401411
results[webhook.url] = True
402412
logger.info("Test webhook sent successfully to %s", webhook.url)
403413

404-
except requests.RequestException as e:
414+
except (urllib.error.URLError, OSError) as e:
405415
results[webhook.url] = False
406416
logger.error("Test webhook failed for %s: %s", webhook.url, e)
407417

0 commit comments

Comments
 (0)