diff --git a/backend/secuscan/notification_service.py b/backend/secuscan/notification_service.py index 223e18043..5eef26143 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 ) @@ -958,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() @@ -974,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", 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):