Skip to content

Commit e3e688b

Browse files
authored
Merge pull request #63 from cyiallou/fix/test-email
Improve notification error handling and update notebooks
2 parents 50688a7 + 62cc720 commit e3e688b

File tree

7 files changed

+113
-29
lines changed

7 files changed

+113
-29
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66

77
* Expose MicrogridData from microgrid namespace.
88
* Upgrade `frequenz-client-reporting` to minimum `v0.16.0`.
9+
* Small updates to the Alerts Notebook and Solar Maintenance notebook.
910

1011
## New Features
12+
* Notification service: Introduced `NotificationSendError` for structured retry failure handling and updated the logging in `send_test_email()` utility function.
1113

1214
## Bug Fixes
1315

examples/Alerts Notebook.ipynb

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

examples/Solar Maintenance.ipynb

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

src/frequenz/lib/notebooks/notification_service.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,18 +466,26 @@ def send_with_retry(
466466
backoff_factor: Delay factor for (linear) backoff calculation.
467467
max_sleep: Maximum sleep time in seconds.
468468
**kwargs: Keyword arguments for the send_func.
469+
470+
Raises:
471+
NotificationSendError: If the notification fails after all retry attempts.
469472
"""
470473
for attempt in range(retries + 1):
471474
try:
472475
send_func(**kwargs)
473476
_log.info("Successfully sent notification on attempt %d", attempt + 1)
474477
return
475478
except Exception as e: # pylint: disable=broad-except
479+
last_exception = e
476480
_log.error("Attempt %d failed: %s", attempt + 1, e)
477481
if attempt < retries - 1:
478482
linear_backoff = backoff_factor * (attempt + 1)
479483
time.sleep(min(max_sleep, linear_backoff))
480484
_log.error("Failed to send notification after %d retries", retries)
485+
raise NotificationSendError(
486+
"Notification failed after all retry attempts.",
487+
last_exception=last_exception,
488+
)
481489

482490
def start_scheduler(self) -> None:
483491
"""Start the scheduler if configured."""
@@ -636,3 +644,24 @@ def _connect_and_send(
636644
except SMTPException as e:
637645
_log.error("Failed to send email: %s", e)
638646
raise
647+
648+
649+
class NotificationSendError(Exception):
650+
"""Raised when sending a notification fails after all retry attempts."""
651+
652+
def __init__(self, message: str, last_exception: Exception | None = None) -> None:
653+
"""Initialise the error with a message and optional last exception.
654+
655+
Args:
656+
message: Error message.
657+
last_exception: The last exception encountered during the send process.
658+
"""
659+
super().__init__(message)
660+
self.last_exception = last_exception
661+
662+
def __str__(self) -> str:
663+
"""Return a string representation of the error."""
664+
base = super().__str__()
665+
if self.last_exception:
666+
return f"{base} (Caused by: {self.last_exception})"
667+
return base

src/frequenz/lib/notebooks/notification_utils.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,37 @@
88
help users configure and debug their notification settings interactively.
99
"""
1010
import html
11+
import logging
1112
import os
1213
import re
1314
import smtplib
15+
import traceback
1416
from dataclasses import fields
1517
from datetime import UTC, datetime
1618

17-
from frequenz.lib.notebooks.notification_service import EmailConfig, EmailNotification
19+
from frequenz.lib.notebooks.notification_service import (
20+
EmailConfig,
21+
EmailNotification,
22+
NotificationSendError,
23+
)
24+
25+
_log = logging.getLogger(__name__)
1826

1927
EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
2028

2129

22-
def send_test_email(config: EmailConfig) -> tuple[bool, str]:
30+
def send_test_email(config: EmailConfig, verbose: bool = True) -> bool:
2331
"""Send a test email using the given EmailConfig.
2432
2533
The email message is generated automatically based on the provided
2634
configuration and replaces the provided one.
2735
2836
Args:
2937
config: An EmailConfig instance.
38+
verbose: If True, prints the result to stdout.
3039
3140
Returns:
32-
Tuple of (success_flag, message).
41+
True if the email was sent successfully, False otherwise.
3342
"""
3443
if config.subject is None:
3544
config.subject = "Test Email"
@@ -65,9 +74,21 @@ def send_test_email(config: EmailConfig) -> tuple[bool, str]:
6574
try:
6675
email_notification = EmailNotification(config=config)
6776
email_notification.send()
68-
return True, "✅ Test email sent successfully!"
69-
except Exception as e: # pylint: disable=broad-except
70-
return False, f"❌ Error sending test email: {e}"
77+
msg = "✅ Test email sent successfully!"
78+
_log.info(msg)
79+
if verbose:
80+
print(msg)
81+
return True
82+
except NotificationSendError as e:
83+
msg = f"❌ Error sending test email: {e}"
84+
_log.error(msg)
85+
if e.last_exception:
86+
_log.debug(
87+
"Traceback:\n%s", "".join(traceback.format_exception(e.last_exception))
88+
)
89+
if verbose:
90+
print(msg)
91+
return False
7192

7293

7394
def format_email_preview(

tests/test_notification_service.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
EmailConfig,
1616
EmailNotification,
1717
FromDictMixin,
18+
NotificationSendError,
1819
Scheduler,
1920
SchedulerConfig,
2021
)
@@ -140,13 +141,16 @@ def test_send_with_retry_failure(
140141
"""Test that send_with_retry raises after all retries fail."""
141142
mock_send.side_effect = Exception("Always fails")
142143
with caplog.at_level(logging.ERROR):
143-
BaseNotification.send_with_retry(
144-
send_func=mock_send,
145-
retries=3,
146-
backoff_factor=1,
147-
max_sleep=2,
148-
)
144+
with pytest.raises(NotificationSendError) as exc_info:
145+
BaseNotification.send_with_retry(
146+
send_func=mock_send,
147+
retries=3,
148+
backoff_factor=1,
149+
max_sleep=2,
150+
)
149151
assert "Failed to send notification after 3 retries" in caplog.text
152+
assert "Always fails" in str(exc_info.value)
153+
assert "notification failed" in str(exc_info.value).lower()
150154
assert mock_send.call_count == 4
151155

152156

@@ -168,12 +172,13 @@ def test_send_with_retry_backoff(mock_send: MagicMock, mock_sleep: MagicMock) ->
168172
retries = 3
169173
backoff_factor = 2
170174
max_sleep = 3
171-
BaseNotification.send_with_retry(
172-
send_func=mock_send,
173-
retries=retries,
174-
backoff_factor=backoff_factor,
175-
max_sleep=max_sleep,
176-
)
175+
with pytest.raises(NotificationSendError):
176+
BaseNotification.send_with_retry(
177+
send_func=mock_send,
178+
retries=retries,
179+
backoff_factor=backoff_factor,
180+
max_sleep=max_sleep,
181+
)
177182

178183
# Expected sleep durations for each retry
179184
expected_sleep_calls = [
@@ -344,3 +349,19 @@ class TestClass:
344349

345350
with pytest.raises(AttributeError, match="has no attribute 'from_dict'"):
346351
TestClass.from_dict({}) # type: ignore
352+
353+
354+
# Tests for the NotificationSendError class
355+
def test_notification_send_error_str_with_cause() -> None:
356+
"""Test NotificationSendError string representation with a cause."""
357+
original_error = ValueError("invalid email address")
358+
exc = NotificationSendError(
359+
"Failed to send notification", last_exception=original_error
360+
)
361+
assert str(exc) == "Failed to send notification (Caused by: invalid email address)"
362+
363+
364+
def test_notification_send_error_str_without_cause() -> None:
365+
"""Test NotificationSendError string representation without a cause."""
366+
exc = NotificationSendError("Retry attempts exhausted")
367+
assert str(exc) == "Retry attempts exhausted"

tests/test_notification_utils.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
from unittest.mock import MagicMock, patch
77

88
import pytest
9+
from _pytest.logging import LogCaptureFixture
910

1011
from frequenz.lib.notebooks import notification_utils
11-
from frequenz.lib.notebooks.notification_service import EmailConfig
12+
from frequenz.lib.notebooks.notification_service import (
13+
EmailConfig,
14+
NotificationSendError,
15+
)
1216

1317

1418
@pytest.fixture
@@ -177,19 +181,26 @@ def test_format_email_preview_without_attachments() -> None:
177181

178182
@patch("frequenz.lib.notebooks.notification_service.EmailNotification.send")
179183
def test_send_test_email_success(basic_email_config: EmailConfig) -> None:
180-
"""Test that send_test_email reports success correctly."""
181-
success, msg = notification_utils.send_test_email(basic_email_config)
184+
"""Test that send_test_email logs success correctly."""
185+
success = notification_utils.send_test_email(basic_email_config, verbose=False)
182186
assert success is True
183-
assert "successfully" in msg.lower()
184187

185188

186189
@patch("frequenz.lib.notebooks.notification_service.EmailNotification.send")
187190
def test_send_test_email_handles_failure(
188-
mock_send: MagicMock, basic_email_config: EmailConfig
191+
mock_send: MagicMock,
192+
caplog: LogCaptureFixture,
193+
basic_email_config: EmailConfig,
189194
) -> None:
190-
"""Test that send_test_email catches and reports errors."""
191-
mock_send.side_effect = Exception("Failed to send")
195+
"""Test that send_test_email logs error and traceback on failure."""
196+
cause = RuntimeError("timeout")
197+
exception = NotificationSendError("Retry failed", last_exception=cause)
198+
mock_send.side_effect = exception # Important: patch `send`, not `send_with_retry`
199+
200+
with caplog.at_level("DEBUG", logger="frequenz.lib.notebooks.notification_utils"):
201+
success = notification_utils.send_test_email(basic_email_config, verbose=False)
192202

193-
success, msg = notification_utils.send_test_email(basic_email_config)
194203
assert success is False
195-
assert "error" in msg.lower()
204+
assert any("retry failed" in msg.lower() for msg in caplog.messages)
205+
assert any("traceback" in msg.lower() for msg in caplog.messages)
206+
assert any("timeout" in msg.lower() for msg in caplog.messages)

0 commit comments

Comments
 (0)