Skip to content

Commit

Permalink
feat: Call webhooks async and add backoff to webhooks (#2932)
Browse files Browse the repository at this point in the history
Co-authored-by: Kim Gustyr <khvn26@gmail.com>
  • Loading branch information
tushar5526 and khvn26 committed Jan 4, 2024
1 parent f98de7b commit 445c698
Show file tree
Hide file tree
Showing 10 changed files with 309 additions and 101 deletions.
3 changes: 3 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 3 additions & 1 deletion api/audit/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion api/edge_api/identities/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 9 additions & 12 deletions api/features/tasks.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion api/task_processor/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion api/tests/unit/audit/test_unit_audit_models.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
67 changes: 38 additions & 29 deletions api/tests/unit/features/test_unit_features_tasks.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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

3 comments on commit 445c698

@vercel
Copy link

@vercel vercel bot commented on 445c698 Jan 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

docs – ./docs

docs-flagsmith.vercel.app
docs-git-main-flagsmith.vercel.app
docs.bullet-train.io
docs.flagsmith.com

@vercel
Copy link

@vercel vercel bot commented on 445c698 Jan 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 445c698 Jan 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.