From 445c69837b43e341d6ad48a1a9fd7d12af47a115 Mon Sep 17 00:00:00 2001 From: Tushar <30565750+tushar5526@users.noreply.github.com> Date: Thu, 4 Jan 2024 15:15:55 +0530 Subject: [PATCH] feat: Call webhooks async and add backoff to webhooks (#2932) Co-authored-by: Kim Gustyr --- api/app/settings/common.py | 3 + api/audit/signals.py | 4 +- api/edge_api/identities/tasks.py | 2 +- api/features/tasks.py | 21 +- api/task_processor/decorators.py | 2 +- .../unit/audit/test_unit_audit_models.py | 10 +- .../test_unit_edge_api_identities_tasks.py | 12 +- .../unit/features/test_unit_features_tasks.py | 67 +++--- api/tests/unit/webhooks/test_unit_webhooks.py | 96 +++++++-- api/webhooks/webhooks.py | 193 ++++++++++++++---- 10 files changed, 309 insertions(+), 101 deletions(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 812810cad794..891097e4b916 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -1073,3 +1073,6 @@ # The LDAP user username and password used by `sync_ldap_users_and_groups` command LDAP_SYNC_USER_USERNAME = env.str("LDAP_SYNC_USER_USERNAME", None) LDAP_SYNC_USER_PASSWORD = env.str("LDAP_SYNC_USER_PASSWORD", None) + +WEBHOOK_BACKOFF_BASE = env.int("WEBHOOK_BACKOFF_BASE", default=2) +WEBHOOK_BACKOFF_RETRIES = env.int("WEBHOOK_BACKOFF_RETRIES", default=3) diff --git a/api/audit/signals.py b/api/audit/signals.py index 6ab7bddd6eb0..a0cb322ce80a 100644 --- a/api/audit/signals.py +++ b/api/audit/signals.py @@ -27,7 +27,9 @@ def call_webhooks(sender, instance, **kwargs): if instance.project else instance.environment.project.organisation ) - call_organisation_webhooks(organisation, data, WebhookEventType.AUDIT_LOG_CREATED) + call_organisation_webhooks.delay( + args=(organisation.id, data, WebhookEventType.AUDIT_LOG_CREATED.value) + ) def _get_integration_config(instance, integration_name): diff --git a/api/edge_api/identities/tasks.py b/api/edge_api/identities/tasks.py index 5a13936a973a..fb99729c9c54 100644 --- a/api/edge_api/identities/tasks.py +++ b/api/edge_api/identities/tasks.py @@ -74,7 +74,7 @@ def call_environment_webhook_for_feature_state_change( else WebhookEventType.FLAG_UPDATED ) - call_environment_webhooks(environment, data, event_type=event_type) + call_environment_webhooks(environment.id, data, event_type=event_type.value) @register_task_handler(priority=TaskPriority.HIGH) diff --git a/api/features/tasks.py b/api/features/tasks.py index 1a656ea59620..c68b7d92f61e 100644 --- a/api/features/tasks.py +++ b/api/features/tasks.py @@ -1,5 +1,3 @@ -from threading import Thread - from environments.models import Webhook from features.models import FeatureState from webhooks.constants import WEBHOOK_DATETIME_FORMAT @@ -38,19 +36,18 @@ def trigger_feature_state_change_webhooks( previous_state = _get_previous_state(history_instance, event_type) if previous_state: data.update(previous_state=previous_state) - Thread( - target=call_environment_webhooks, - args=(instance.environment, data, event_type), - ).start() - Thread( - target=call_organisation_webhooks, + call_environment_webhooks.delay( + args=(instance.environment.id, data, event_type.value) + ) + + call_organisation_webhooks.delay( args=( - instance.environment.project.organisation, + instance.environment.project.organisation.id, data, - event_type, - ), - ).start() + event_type.value, + ) + ) def _get_previous_state( diff --git a/api/task_processor/decorators.py b/api/task_processor/decorators.py index 6819b85d1fd9..793875c99a47 100644 --- a/api/task_processor/decorators.py +++ b/api/task_processor/decorators.py @@ -59,7 +59,7 @@ def delay( delay_until: datetime | None = None, # TODO @khvn26 consider typing `args` and `kwargs` with `ParamSpec` # (will require a change to the signature) - args: tuple[typing.Any] = (), + args: tuple[typing.Any, ...] = (), kwargs: dict[str, typing.Any] | None = None, ) -> Task | None: logger.debug("Request to run task '%s' asynchronously.", self.task_identifier) diff --git a/api/tests/unit/audit/test_unit_audit_models.py b/api/tests/unit/audit/test_unit_audit_models.py index e6c506bb0d4d..b8a4efe86fdc 100644 --- a/api/tests/unit/audit/test_unit_audit_models.py +++ b/api/tests/unit/audit/test_unit_audit_models.py @@ -1,6 +1,8 @@ from audit.models import AuditLog from audit.related_object_type import RelatedObjectType +from audit.serializers import AuditLogSerializer from integrations.datadog.models import DataDogConfiguration +from webhooks.webhooks import WebhookEventType def test_organisation_webhooks_are_called_when_audit_log_saved(project, mocker): @@ -13,7 +15,13 @@ def test_organisation_webhooks_are_called_when_audit_log_saved(project, mocker): audit_log.save() # Then - mock_call_webhooks.assert_called() + mock_call_webhooks.delay.assert_called_once_with( + args=( + project.organisation.id, + AuditLogSerializer(instance=audit_log).data, + WebhookEventType.AUDIT_LOG_CREATED.value, + ) + ) def test_data_dog_track_event_not_called_on_audit_log_saved_when_not_configured( diff --git a/api/tests/unit/edge_api/identities/test_unit_edge_api_identities_tasks.py b/api/tests/unit/edge_api/identities/test_unit_edge_api_identities_tasks.py index 0b111f991794..d5b6d29cea51 100644 --- a/api/tests/unit/edge_api/identities/test_unit_edge_api_identities_tasks.py +++ b/api/tests/unit/edge_api/identities/test_unit_edge_api_identities_tasks.py @@ -58,8 +58,8 @@ def test_call_environment_webhook_for_feature_state_change_with_new_state_only( mock_call_environment_webhooks.assert_called_once() call_args = mock_call_environment_webhooks.call_args - assert call_args[0][0] == environment - assert call_args[1]["event_type"] == WebhookEventType.FLAG_UPDATED + assert call_args[0][0] == environment.id + assert call_args[1]["event_type"] == WebhookEventType.FLAG_UPDATED.value mock_generate_webhook_feature_state_data.assert_called_once_with( feature=feature, @@ -111,8 +111,8 @@ def test_call_environment_webhook_for_feature_state_change_with_previous_state_o mock_call_environment_webhooks.assert_called_once() call_args = mock_call_environment_webhooks.call_args - assert call_args[0][0] == environment - assert call_args[1]["event_type"] == WebhookEventType.FLAG_DELETED + assert call_args[0][0] == environment.id + assert call_args[1]["event_type"] == WebhookEventType.FLAG_DELETED.value mock_generate_webhook_feature_state_data.assert_called_once_with( feature=feature, @@ -182,8 +182,8 @@ def test_call_environment_webhook_for_feature_state_change_with_both_states( mock_call_environment_webhooks.assert_called_once() call_args = mock_call_environment_webhooks.call_args - assert call_args[0][0] == environment - assert call_args[1]["event_type"] == WebhookEventType.FLAG_UPDATED + assert call_args[0][0] == environment.id + assert call_args[1]["event_type"] == WebhookEventType.FLAG_UPDATED.value assert mock_generate_webhook_feature_state_data.call_count == 2 mock_generate_data_calls = mock_generate_webhook_feature_state_data.call_args_list diff --git a/api/tests/unit/features/test_unit_features_tasks.py b/api/tests/unit/features/test_unit_features_tasks.py index 340f8290b703..769e7e95e95a 100644 --- a/api/tests/unit/features/test_unit_features_tasks.py +++ b/api/tests/unit/features/test_unit_features_tasks.py @@ -1,6 +1,5 @@ -from unittest import mock - import pytest +from pytest_mock import MockerFixture from environments.models import Environment from features.models import Feature, FeatureState @@ -11,8 +10,7 @@ @pytest.mark.django_db -@mock.patch("features.tasks.Thread") -def test_trigger_feature_state_change_webhooks(MockThread): +def test_trigger_feature_state_change_webhooks(mocker: MockerFixture): # Given initial_value = "initial" new_value = "new" @@ -30,34 +28,40 @@ def test_trigger_feature_state_change_webhooks(MockThread): feature_state.feature_state_value.save() feature_state.save() - MockThread.reset_mock() # reset mock as it will have been called when setting up the data + mock_call_environment_webhooks = mocker.patch( + "features.tasks.call_environment_webhooks" + ) + mock_call_organisation_webhooks = mocker.patch( + "features.tasks.call_organisation_webhooks" + ) # When trigger_feature_state_change_webhooks(feature_state) # Then - call_list = MockThread.call_args_list + environment_webhook_call_args = ( + mock_call_environment_webhooks.delay.call_args.kwargs["args"] + ) + organisation_webhook_call_args = ( + mock_call_organisation_webhooks.delay.call_args.kwargs["args"] + ) - environment_webhook_call_args = call_list[0] - organisation_webhook_call_args = call_list[1] + assert environment_webhook_call_args[0] == environment.id + assert organisation_webhook_call_args[0] == organisation.id # verify that the data for both calls is the same - assert ( - environment_webhook_call_args[1]["args"][1] - == organisation_webhook_call_args[1]["args"][1] - ) + assert environment_webhook_call_args[1] == organisation_webhook_call_args[1] - data = environment_webhook_call_args[1]["args"][1] - event_type = environment_webhook_call_args[1]["args"][2] + data = environment_webhook_call_args[1] + event_type = environment_webhook_call_args[2] assert data["new_state"]["feature_state_value"] == new_value assert data["previous_state"]["feature_state_value"] == initial_value - assert event_type == WebhookEventType.FLAG_UPDATED + assert event_type == WebhookEventType.FLAG_UPDATED.value @pytest.mark.django_db -@mock.patch("features.tasks.Thread") def test_trigger_feature_state_change_webhooks_for_deleted_flag( - MockThread, organisation, project, environment, feature + mocker, organisation, project, environment, feature ): # Given new_value = "new" @@ -68,23 +72,28 @@ def test_trigger_feature_state_change_webhooks_for_deleted_flag( feature_state.feature_state_value.save() feature_state.save() - MockThread.reset_mock() # reset mock as it will have been called when setting up the data + mock_call_environment_webhooks = mocker.patch( + "features.tasks.call_environment_webhooks" + ) + mock_call_organisation_webhooks = mocker.patch( + "features.tasks.call_organisation_webhooks" + ) + trigger_feature_state_change_webhooks(feature_state, WebhookEventType.FLAG_DELETED) # Then - call_list = MockThread.call_args_list - - environment_webhook_call_args = call_list[0] - organisation_webhook_call_args = call_list[1] + environment_webhook_call_args = ( + mock_call_environment_webhooks.delay.call_args.kwargs["args"] + ) + organisation_webhook_call_args = ( + mock_call_organisation_webhooks.delay.call_args.kwargs["args"] + ) # verify that the data for both calls is the same - assert ( - environment_webhook_call_args[1]["args"][1] - == organisation_webhook_call_args[1]["args"][1] - ) + assert environment_webhook_call_args[1] == organisation_webhook_call_args[1] - data = environment_webhook_call_args[1]["args"][1] - event_type = environment_webhook_call_args[1]["args"][2] + data = environment_webhook_call_args[1] + event_type = environment_webhook_call_args[2] assert data["new_state"] is None assert data["previous_state"]["feature_state_value"] == new_value - assert event_type == WebhookEventType.FLAG_DELETED + assert event_type == WebhookEventType.FLAG_DELETED.value diff --git a/api/tests/unit/webhooks/test_unit_webhooks.py b/api/tests/unit/webhooks/test_unit_webhooks.py index 0f5fad1215bb..b226db889ec7 100644 --- a/api/tests/unit/webhooks/test_unit_webhooks.py +++ b/api/tests/unit/webhooks/test_unit_webhooks.py @@ -20,6 +20,8 @@ WebhookEventType, WebhookType, call_environment_webhooks, + call_organisation_webhooks, + call_webhook_with_failure_mail_after_retries, trigger_sample_webhook, ) @@ -45,9 +47,9 @@ def test_requests_made_to_all_urls_for_environment(self, mock_requests): # When call_environment_webhooks( - environment=self.environment, + environment_id=self.environment.id, data={}, - event_type=WebhookEventType.FLAG_UPDATED, + event_type=WebhookEventType.FLAG_UPDATED.value, ) # Then @@ -70,9 +72,9 @@ def test_request_not_made_to_disabled_webhook(self, mock_requests): # When call_environment_webhooks( - environment=self.environment, + environment_id=self.environment.id, data={}, - event_type=WebhookEventType.FLAG_UPDATED, + event_type=WebhookEventType.FLAG_UPDATED.value, ) # Then @@ -124,9 +126,9 @@ def test_request_made_with_correct_signature( ).hexdigest() call_environment_webhooks( - environment=self.environment, + environment_id=self.environment.id, data={}, - event_type=WebhookEventType.FLAG_UPDATED, + event_type=WebhookEventType.FLAG_UPDATED.value, ) # When _, kwargs = mock_requests.post.call_args_list[0] @@ -144,9 +146,9 @@ def test_request_does_not_have_signature_header_if_secret_is_not_set( ) # When call_environment_webhooks( - environment=self.environment, + environment_id=self.environment.id, data={}, - event_type=WebhookEventType.FLAG_UPDATED, + event_type=WebhookEventType.FLAG_UPDATED.value, ) # Then @@ -155,6 +157,7 @@ def test_request_does_not_have_signature_header_if_secret_is_not_set( @pytest.mark.parametrize("expected_error", [ConnectionError, Timeout]) +@pytest.mark.django_db def test_call_environment_webhooks__multiple_webhooks__failure__calls_expected( mocker: MockerFixture, expected_error: Type[Exception], @@ -179,36 +182,103 @@ def test_call_environment_webhooks__multiple_webhooks__failure__calls_expected( ) expected_data = {} - expected_event_type = WebhookEventType.FLAG_UPDATED + expected_event_type = WebhookEventType.FLAG_UPDATED.value expected_send_failure_email_data = { - "event_type": expected_event_type.value, + "event_type": expected_event_type, "data": expected_data, } expected_send_failure_status_code = f"N/A ({expected_error.__name__})" + retries = 4 # When call_environment_webhooks( - environment=environment, + environment_id=environment.id, data=expected_data, event_type=expected_event_type, + retries=retries, ) # Then + assert requests_post_mock.call_count == 2 * retries assert send_failure_email_mock.call_count == 2 send_failure_email_mock.assert_has_calls( [ mocker.call( webhook_2, expected_send_failure_email_data, - WebhookType.ENVIRONMENT, + WebhookType.ENVIRONMENT.value, expected_send_failure_status_code, ), mocker.call( webhook_1, expected_send_failure_email_data, - WebhookType.ENVIRONMENT, + WebhookType.ENVIRONMENT.value, expected_send_failure_status_code, ), ], any_order=True, ) + + +@pytest.mark.parametrize("expected_error", [ConnectionError, Timeout]) +@pytest.mark.django_db +def test_call_organisation_webhooks__multiple_webhooks__failure__calls_expected( + mocker: MockerFixture, expected_error: Type[Exception], organisation: Organisation +) -> None: + # Given + requests_post_mock = mocker.patch("webhooks.webhooks.requests.post") + requests_post_mock.side_effect = expected_error() + send_failure_email_mock: mock.Mock = mocker.patch( + "webhooks.webhooks.send_failure_email" + ) + + webhook_1 = OrganisationWebhook.objects.create( + url="http://url.1.com", enabled=True, organisation=organisation + ) + webhook_2 = OrganisationWebhook.objects.create( + url="http://url.2.com", enabled=True, organisation=organisation + ) + + expected_data = {} + expected_event_type = WebhookEventType.FLAG_UPDATED.value + expected_send_failure_email_data = { + "event_type": expected_event_type, + "data": expected_data, + } + expected_send_failure_status_code = f"N/A ({expected_error.__name__})" + + retries = 5 + # When + call_organisation_webhooks( + organisation_id=organisation.id, + data=expected_data, + event_type=expected_event_type, + retries=retries, + ) + + # Then + assert requests_post_mock.call_count == 2 * retries + assert send_failure_email_mock.call_count == 2 + send_failure_email_mock.assert_has_calls( + [ + mocker.call( + webhook_2, + expected_send_failure_email_data, + WebhookType.ORGANISATION.value, + expected_send_failure_status_code, + ), + mocker.call( + webhook_1, + expected_send_failure_email_data, + WebhookType.ORGANISATION.value, + expected_send_failure_status_code, + ), + ], + any_order=True, + ) + + +def test_call_webhook_with_failure_mail_after_retries_raises_error_on_invalid_args(): + try_count = 10 + with pytest.raises(ValueError): + call_webhook_with_failure_mail_after_retries(0, {}, "", try_count=try_count) diff --git a/api/webhooks/webhooks.py b/api/webhooks/webhooks.py index 5ff4971ff317..cae1b2c7602e 100644 --- a/api/webhooks/webhooks.py +++ b/api/webhooks/webhooks.py @@ -2,7 +2,9 @@ import json import logging import typing +from typing import Type, Union +import backoff import requests from core.constants import FLAGSMITH_SIGNATURE_HEADER from core.signing import sign_payload @@ -12,22 +14,24 @@ from django.template.loader import get_template from django.utils import timezone -from environments.models import Webhook +from environments.models import Environment, Webhook from organisations.models import OrganisationWebhook +from projects.models import Organisation +from task_processor.decorators import register_task_handler +from task_processor.task_run_method import TaskRunMethod from webhooks.sample_webhook_data import ( environment_webhook_data, organisation_webhook_data, ) -from .models import AbstractBaseExportableWebhookModel +from .models import AbstractBaseWebhookModel from .serializers import WebhookSerializer if typing.TYPE_CHECKING: import environments # noqa logger = logging.getLogger(__name__) - -WebhookModels = typing.Union[OrganisationWebhook, "environments.models.Webhook"] +WebhookModels = typing.Union[OrganisationWebhook, Webhook] class WebhookEventType(enum.Enum): @@ -50,53 +54,98 @@ class WebhookType(enum.Enum): def get_webhook_model( webhook_type: WebhookType, -) -> typing.Union[typing.Type[WebhookModels]]: +) -> Type[Union[OrganisationWebhook, Webhook]]: if webhook_type == WebhookType.ORGANISATION: return OrganisationWebhook if webhook_type == WebhookType.ENVIRONMENT: return Webhook -def call_environment_webhooks(environment, data, event_type): +@register_task_handler() +def call_environment_webhooks( + environment_id: int, + data: typing.Mapping, + event_type: str, + retries: int = settings.WEBHOOK_BACKOFF_RETRIES, +): + """ + Call environment webhooks. + + :param environment_id: The ID of the environment for which webhooks will be triggered. + :param data: A mapping containing the data to be sent in the webhook request. + :param event_type: The type of event to trigger the webhook. + :param retries: The number of times to retry the webhook in case of failure (int, default is 3). + """ + if settings.DISABLE_WEBHOOKS: return - + try: + environment = Environment.objects.get(id=environment_id) + except Environment.DoesNotExist: + return _call_webhooks( environment.webhooks.filter(enabled=True), data, event_type, WebhookType.ENVIRONMENT, + retries, ) -def call_organisation_webhooks(organisation, data, event_type): +@register_task_handler() +def call_organisation_webhooks( + organisation_id: int, + data: typing.Mapping, + event_type: str, + retries: int = settings.WEBHOOK_BACKOFF_RETRIES, +): + """ + Call organisation webhooks. + + :param organisation_id: The ID of the organisation for which webhooks will be triggered. + :param data: A mapping containing the data to be sent in the webhook request. + :param event_type: The type of event to trigger the webhook. + :param retries: The number of times to retry the webhook in case of failure (int, default is 3). + """ + if settings.DISABLE_WEBHOOKS: return - + try: + organisation = Organisation.objects.get(id=organisation_id) + except Organisation.DoesNotExist: + return _call_webhooks( organisation.webhooks.filter(enabled=True), data, event_type, WebhookType.ORGANISATION, + retries, ) -def call_integration_webhook(config, data): +def call_integration_webhook(config: AbstractBaseWebhookModel, data: typing.Mapping): return _call_webhook(config, data) def trigger_sample_webhook( - webhook: typing.Type[AbstractBaseExportableWebhookModel], webhook_type: WebhookType + webhook: AbstractBaseWebhookModel, webhook_type: WebhookType ) -> requests.models.Response: data = WEBHOOK_SAMPLE_DATA.get(webhook_type) serializer = WebhookSerializer(data=data) serializer.is_valid(raise_exception=True) - + """ + :raises requests.exceptions.RequestException: If an error occurs while making the request to the webhook. + """ return _call_webhook(webhook, serializer.data) +@backoff.on_exception( + wait_gen=backoff.expo, + exception=requests.exceptions.RequestException, + max_tries=settings.WEBHOOK_BACKOFF_RETRIES, +) def _call_webhook( - webhook: typing.Type[AbstractBaseExportableWebhookModel], + webhook: AbstractBaseWebhookModel, data: typing.Mapping, ) -> requests.models.Response: headers = {"content-type": "application/json"} @@ -106,51 +155,116 @@ def _call_webhook( headers.update({FLAGSMITH_SIGNATURE_HEADER: signature}) try: - return requests.post( + res = requests.post( str(webhook.url), data=json_data, headers=headers, timeout=10 ) + res.raise_for_status() + return res except requests.exceptions.RequestException as exc: logger.debug("Error calling webhook", exc_info=exc) raise -def _call_webhook_email_on_error( - webhook: WebhookModels, data: typing.Mapping, webhook_type: WebhookType +@register_task_handler() +def call_webhook_with_failure_mail_after_retries( + webhook_id: int, + data: typing.Mapping, + webhook_type: str, + send_failure_mail: bool = False, + max_retries: int = settings.WEBHOOK_BACKOFF_RETRIES, + try_count: int = 1, ): + """ + Call a webhook with support for sending failure emails after retries. + + :param webhook_id: The ID of the webhook to be called. + :param data: A mapping containing the data to be sent in the webhook request. + :param webhook_type: The type of the webhook to be triggered. + :param send_failure_mail: Whether to send a failure notification email (bool, default is False). + :param max_retries: The maximum number of retries to attempt (int, default is 3). + :param try_count: Stores the current retry attempt count in scheduled tasks, + not needed to be specified (int, default is 1). + """ + + if try_count > max_retries: + raise ValueError("try_count can't be greater than max_retries") + + if webhook_type == WebhookType.ORGANISATION.value: + webhook = OrganisationWebhook.objects.get(id=webhook_id) + else: + webhook = Webhook.objects.get(id=webhook_id) + + headers = {"content-type": "application/json"} + json_data = json.dumps(data, sort_keys=True, cls=DjangoJSONEncoder) + if webhook.secret: + signature = sign_payload(json_data, key=webhook.secret) + headers.update({FLAGSMITH_SIGNATURE_HEADER: signature}) + try: - res = _call_webhook(webhook, data) - except requests.exceptions.RequestException as exc: - send_failure_email( - webhook, - data, - webhook_type, - f"N/A ({exc.__class__.__name__})", + res = requests.post( + str(webhook.url), data=json_data, headers=headers, timeout=10 ) + res.raise_for_status() + except requests.exceptions.RequestException as exc: + if try_count == max_retries: + if send_failure_mail: + send_failure_email( + webhook, + data, + webhook_type, + f"{f'HTTP {exc.response.status_code}' if exc.response else 'N/A'} ({exc.__class__.__name__})", + ) + else: + call_webhook_with_failure_mail_after_retries.delay( + delay_until=( + timezone.now() + + timezone.timedelta( + seconds=settings.WEBHOOK_BACKOFF_BASE**try_count + ) + if settings.TASK_RUN_METHOD == TaskRunMethod.TASK_PROCESSOR + else None + ), + args=( + webhook_id, + data, + webhook_type, + send_failure_mail, + max_retries, + try_count + 1, + ), + ) return + return res - if res.status_code != 200: - send_failure_email(webhook, data, webhook_type, res.status_code) - -def _call_webhooks(webhooks, data, event_type, webhook_type): - webhook_data = { - "event_type": event_type.value, - "data": data, - "triggered_at": timezone.now().isoformat(), - } +def _call_webhooks( + webhooks: typing.Iterable[WebhookModels], + data: typing.Mapping, + event_type: str, + webhook_type: WebhookType, + retries: int = settings.WEBHOOK_BACKOFF_RETRIES, +): + webhook_data = {"event_type": event_type, "data": data} serializer = WebhookSerializer(data=webhook_data) serializer.is_valid(raise_exception=False) for webhook in webhooks: - _call_webhook_email_on_error(webhook, serializer.data, webhook_type) + call_webhook_with_failure_mail_after_retries.delay( + args=(webhook.id, serializer.data, webhook_type.value, True, retries) + ) -def send_failure_email(webhook, data, webhook_type, status_code=None): +def send_failure_email( + webhook: WebhookModels, + data: typing.Mapping, + webhook_type: str, + status_code: typing.Union[int, str] = None, +): template_data = _get_failure_email_template_data( webhook, data, webhook_type, status_code ) organisation = ( webhook.organisation - if webhook_type == WebhookType.ORGANISATION + if webhook_type == WebhookType.ORGANISATION.value else webhook.environment.project.organisation ) @@ -167,14 +281,19 @@ def send_failure_email(webhook, data, webhook_type, status_code=None): msg.send() -def _get_failure_email_template_data(webhook, data, webhook_type, status_code=None): +def _get_failure_email_template_data( + webhook: WebhookModels, + data: typing.Mapping, + webhook_type: str, + status_code: typing.Union[int, str] = None, +): data = { "status_code": status_code, "data": json.dumps(data, sort_keys=True, indent=2, cls=DjangoJSONEncoder), "webhook_url": webhook.url, } - if webhook_type == WebhookType.ENVIRONMENT: + if webhook_type == WebhookType.ENVIRONMENT.value: data["project_name"] = webhook.environment.project.name data["environment_name"] = webhook.environment.name