Repository: saleor/saleor

Issue:
Prevent webhooks with 4xx response to be retried
AC

- [ ] Only 5xx responses should trigger webhooks to be retried
- [ ] Update docs -> describe how webhooks retry model works


Discussion:

Resolving patch:
diff --git a/saleor/webhook/transport/asynchronous/transport.py b/saleor/webhook/transport/asynchronous/transport.py
--- a/saleor/webhook/transport/asynchronous/transport.py
+++ b/saleor/webhook/transport/asynchronous/transport.py
@@ -203,7 +203,7 @@ def send_webhook_request_async(self, event_delivery_id):
 
         attempt_update(attempt, response)
         if response.status == EventDeliveryStatus.FAILED:
-            handle_webhook_retry(self, webhook, response.content, delivery, attempt)
+            handle_webhook_retry(self, webhook, response, delivery, attempt)
             delivery_status = EventDeliveryStatus.FAILED
         elif response.status == EventDeliveryStatus.SUCCESS:
             task_logger.info(
diff --git a/saleor/webhook/transport/synchronous/transport.py b/saleor/webhook/transport/synchronous/transport.py
--- a/saleor/webhook/transport/synchronous/transport.py
+++ b/saleor/webhook/transport/synchronous/transport.py
@@ -72,9 +72,7 @@ def handle_transaction_request_task(self, delivery_id, request_event_id):
     attempt = create_attempt(delivery, self.request.id)
     response, response_data = _send_webhook_request_sync(delivery, attempt=attempt)
     if response.response_status_code and response.response_status_code >= 500:
-        handle_webhook_retry(
-            self, delivery.webhook, response.content, delivery, attempt
-        )
+        handle_webhook_retry(self, delivery.webhook, response, delivery, attempt)
         response_data = None
     create_transaction_event_from_request_and_webhook_response(
         request_event,
diff --git a/saleor/webhook/transport/utils.py b/saleor/webhook/transport/utils.py
--- a/saleor/webhook/transport/utils.py
+++ b/saleor/webhook/transport/utils.py
@@ -11,6 +11,7 @@
 
 import boto3
 from botocore.exceptions import ClientError
+from celery import Task
 from celery.exceptions import MaxRetriesExceededError, Retry
 from celery.utils.log import get_task_logger
 from django.conf import settings
@@ -43,6 +44,7 @@
 from .. import observability
 from ..const import APP_ID_PREFIX
 from ..event_types import WebhookEventSyncType
+from ..models import Webhook
 from . import signature_for_payload
 
 logger = logging.getLogger(__name__)
@@ -167,7 +169,9 @@ def send_webhook_using_http(
         response_status_code=response.status_code,
         duration=response.elapsed.total_seconds(),
         status=(
-            EventDeliveryStatus.SUCCESS if response.ok else EventDeliveryStatus.FAILED
+            EventDeliveryStatus.SUCCESS
+            if 200 <= response.status_code < 300
+            else EventDeliveryStatus.FAILED
         ),
     )
 
@@ -286,7 +290,11 @@ def send_webhook_using_scheme_method(
 
 
 def handle_webhook_retry(
-    celery_task, webhook, response_content, delivery, delivery_attempt
+    celery_task: Task,
+    webhook: Webhook,
+    response: WebhookResponse,
+    delivery: EventDelivery,
+    delivery_attempt: EventDeliveryAttempt,
 ) -> bool:
     """Handle celery retry for webhook requests.
 
@@ -299,10 +307,20 @@ def handle_webhook_retry(
         " Delivery attempt id: %r",
         webhook.id,
         webhook.target_url,
-        response_content,
+        response.content,
         delivery.event_type,
         delivery_attempt.id,
     )
+    if response.response_status_code and 300 <= response.response_status_code < 500:
+        # do not retry for 30x and 40x status codes
+        task_logger.info(
+            "[Webhook ID: %r] Failed request to %r: received HTTP %d. Delivery ID: %r",
+            webhook.id,
+            webhook.target_url,
+            response.response_status_code,
+            delivery.id,
+        )
+        return False
     try:
         countdown = celery_task.retry_backoff * (2**celery_task.request.retries)
         celery_task.retry(countdown=countdown, **celery_task.retry_kwargs)
@@ -313,8 +331,7 @@ def handle_webhook_retry(
     except MaxRetriesExceededError:
         is_success = False
         task_logger.info(
-            "[Webhook ID: %r] Failed request to %r: exceeded retry limit."
-            "Delivery id: %r",
+            "[Webhook ID: %r] Failed request to %r: exceeded retry limit. Delivery ID: %r",
             webhook.id,
             webhook.target_url,
             delivery.id,


Test patch:
diff --git a/saleor/webhook/transport/tests/__init__.py b/saleor/webhook/transport/tests/__init__.py
new file mode 100644
diff --git a/saleor/webhook/transport/tests/test_utils.py b/saleor/webhook/transport/tests/test_utils.py
new file mode 100644
--- /dev/null
+++ b/saleor/webhook/transport/tests/test_utils.py
@@ -0,0 +1,76 @@
+import datetime
+
+import pytest
+from celery import Task
+from celery.exceptions import Retry
+from celery.utils.threads import LocalStack
+
+from ....core import EventDeliveryStatus
+from ....core.models import EventDeliveryAttempt
+from ..utils import WebhookResponse, handle_webhook_retry
+
+
+class DummyTask(Task):
+    retry_backoff = 10
+    retry_kwargs = {"max_retries": 2}
+    request_stack = LocalStack()
+
+
+def test_webhook_retry(webhook, event_delivery):
+    # given
+    attempt = EventDeliveryAttempt(
+        id=1,
+        created_at=datetime.datetime.utcnow(),
+        delivery=event_delivery,
+        request_headers="",
+        task_id="123",
+    )
+    task = DummyTask()
+    # when
+    task.push_request(retries=1)
+    response = WebhookResponse(
+        content="Error!", response_status_code=500, status=EventDeliveryStatus.FAILED
+    )
+    # then
+    with pytest.raises(Retry):
+        handle_webhook_retry(task, webhook, response, event_delivery, attempt)
+
+
+def test_webhook_retry_404(webhook, event_delivery):
+    # given
+    attempt = EventDeliveryAttempt(
+        id=1,
+        created_at=datetime.datetime.utcnow(),
+        delivery=event_delivery,
+        request_headers="",
+        task_id="123",
+    )
+    task = DummyTask()
+    # when
+    task.push_request(retries=1)
+    response = WebhookResponse(
+        content="Error!", response_status_code=404, status=EventDeliveryStatus.FAILED
+    )
+    # then
+    retry = handle_webhook_retry(task, webhook, response, event_delivery, attempt)
+    assert retry is False
+
+
+def test_webhook_retry_redirect(webhook, event_delivery):
+    # given
+    attempt = EventDeliveryAttempt(
+        id=1,
+        created_at=datetime.datetime.utcnow(),
+        delivery=event_delivery,
+        request_headers="",
+        task_id="123",
+    )
+    task = DummyTask()
+    # when
+    task.push_request(retries=1)
+    response = WebhookResponse(
+        content="Error!", response_status_code=302, status=EventDeliveryStatus.FAILED
+    )
+    # then
+    retry = handle_webhook_retry(task, webhook, response, event_delivery, attempt)
+    assert retry is False