From 13064625fcaad28754f04ef019cf45bc90f51c8c Mon Sep 17 00:00:00 2001 From: Tanishka Kuwar Date: Sat, 1 Aug 2026 17:49:02 +0530 Subject: [PATCH 1/2] Add retry support for notification delivery --- backend/secuscan/notification_service.py | 30 ++++++++++++---- .../backend/unit/test_notification_service.py | 36 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/backend/secuscan/notification_service.py b/backend/secuscan/notification_service.py index 223e18043..9bcbf2fe8 100644 --- a/backend/secuscan/notification_service.py +++ b/backend/secuscan/notification_service.py @@ -624,13 +624,31 @@ async def deliver_via_rule( channel = str(rule.get("channel_type", "")).lower() target = str(rule.get("target_url_or_email", "")) - if channel == NotificationChannelType.WEBHOOK.value: - ok, error = await send_webhook(target, payload) - elif channel == NotificationChannelType.EMAIL.value: - ok, error = await send_email(target, payload) - else: - ok, error = False, f"Unsupported channel type: {channel}" + + config = get_delivery_configuration() + max_retries = config["max_retries"] + backoff = config["backoff_factor_seconds"] + + attempt = 0 + + while True: + if channel == NotificationChannelType.WEBHOOK.value: + ok, error = await send_webhook(target, payload) + elif channel == NotificationChannelType.EMAIL.value: + ok, error = await send_email(target, payload) + else: + ok, error = False, f"Unsupported channel type: {channel}" + + if ok: + break + + if attempt >= max_retries: + break + + attempt += 1 + if backoff > 0: + await asyncio.sleep(backoff * attempt) status = ( NotificationDeliveryStatus.SUCCESS if ok else NotificationDeliveryStatus.FAILED ) diff --git a/testing/backend/unit/test_notification_service.py b/testing/backend/unit/test_notification_service.py index cbc1ab1ef..45628a393 100644 --- a/testing/backend/unit/test_notification_service.py +++ b/testing/backend/unit/test_notification_service.py @@ -222,6 +222,42 @@ async def test_deliver_records_failure_on_webhook_error(test_db): assert row["status"] == NotificationDeliveryStatus.FAILED.value assert row["error_message"] == "connection refused" +@pytest.mark.asyncio +async def test_deliver_via_rule_retries_before_success(test_db): + _, finding_id = await _seed_finding(test_db) + rule_id = await _seed_rule(test_db) + + finding = await test_db.fetchone( + "SELECT * FROM findings WHERE id = ?", (finding_id,) + ) + rule = await test_db.fetchone( + "SELECT * FROM notification_rules WHERE id = ?", (rule_id,) + ) + + with ( + patch( + "backend.secuscan.notification_service.get_delivery_configuration", + return_value={ + "webhook_timeout_seconds": 10, + "webhook_connect_timeout_seconds": 3, + "max_retries": 2, + "backoff_factor_seconds": 0, + }, + ), + patch( + "backend.secuscan.notification_service.send_webhook", + new=AsyncMock( + side_effect=[ + (False, "temporary error"), + (True, None), + ] + ), + ) as mock_send, + ): + result = await deliver_via_rule(test_db, rule, finding) + + assert result.status == NotificationDeliveryStatus.SUCCESS + assert mock_send.await_count == 2 @pytest.mark.asyncio async def test_email_placeholder_records_success(test_db): From aa321057fb0253ce67308aade06f6636a4c6c103 Mon Sep 17 00:00:00 2001 From: Tanishka Kuwar Date: Sat, 1 Aug 2026 18:14:14 +0530 Subject: [PATCH 2/2] Remove trailing whitespace --- backend/secuscan/notification_service.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/secuscan/notification_service.py b/backend/secuscan/notification_service.py index 9bcbf2fe8..5eef26143 100644 --- a/backend/secuscan/notification_service.py +++ b/backend/secuscan/notification_service.py @@ -624,7 +624,7 @@ async def deliver_via_rule( channel = str(rule.get("channel_type", "")).lower() target = str(rule.get("target_url_or_email", "")) - + config = get_delivery_configuration() max_retries = config["max_retries"] backoff = config["backoff_factor_seconds"] @@ -976,7 +976,7 @@ async def process_slack_notification(db: Database, task_id: str) -> None: (task_id,), ) total_findings = len(findings) - + severity_counts: Dict[str, int] = {} for row in findings: sev = str(row.get("severity") or "info").lower() @@ -992,7 +992,7 @@ async def process_slack_notification(db: Database, task_id: str) -> None: # Status-specific formatting status_icon = "✅" if status == "COMPLETED" else "❌" if status == "FAILED" else "â„šī¸" - + blocks = [ { "type": "header",