Skip to content

Commit e98fd87

Browse files
committed
fix(cohorts): cap Mixpanel pages and reject syncs to a deleting cohort
1 parent 10d6468 commit e98fd87

5 files changed

Lines changed: 149 additions & 8 deletions

File tree

api/cohorts/serializers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,12 @@ class MixpanelParametersSerializer(serializers.Serializer[None]):
108108
mixpanel_cohort_id = serializers.CharField(max_length=255)
109109
mixpanel_cohort_name = serializers.CharField(max_length=2000)
110110
# An empty page is valid: a first sync of an empty cohort has no members.
111-
members = MixpanelMemberSerializer(many=True, allow_empty=True)
111+
# Mixpanel sends at most 1000 members per message; the cap stops anything
112+
# else from posting an arbitrarily large page.
113+
# The stubs don't know many=True forwards max_length to the list serialiser.
114+
members = MixpanelMemberSerializer( # type: ignore[call-arg]
115+
many=True, allow_empty=True, max_length=1000
116+
)
112117

113118

114119
class MixpanelWebhookSerializer(serializers.Serializer[None]):

api/cohorts/services.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,22 @@ def get_cohort_for_source(
207207
return cohort
208208

209209

210+
def cohort_deletion_in_progress(
211+
*,
212+
environment: "Environment",
213+
source_type: CohortSourceType,
214+
external_id: str,
215+
) -> bool:
216+
return bool(
217+
Cohort.objects.filter(
218+
environment=environment,
219+
source_type=source_type,
220+
external_id=external_id,
221+
deletion_requested_at__isnull=False,
222+
).exists()
223+
)
224+
225+
210226
def add_cohort_members(cohort: Cohort, identifiers: "typing.Iterable[str]") -> None:
211227
from cohorts.tasks import apply_cohort_membership_deltas
212228

api/cohorts/sync_views.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from drf_spectacular.utils import extend_schema, extend_schema_view, inline_serializer
77
from rest_framework import serializers, viewsets
88
from rest_framework.decorators import action
9-
from rest_framework.exceptions import NotFound, ParseError
9+
from rest_framework.exceptions import NotFound, ParseError, ValidationError
1010
from rest_framework.request import Request
1111
from rest_framework.response import Response
1212
from rest_framework.views import APIView
@@ -157,6 +157,17 @@ def post(self, request: Request) -> Response:
157157
environment = typing.cast(CohortSyncKey, request.auth).environment
158158

159159
if webhook_action == "members":
160+
if services.cohort_deletion_in_progress(
161+
environment=environment,
162+
source_type=CohortSourceType.MIXPANEL,
163+
external_id=parameters["mixpanel_cohort_id"],
164+
):
165+
# Recreating the cohort while its memberships are still being
166+
# drained would resurrect it. The 404 pauses the sync and
167+
# emails the customer.
168+
return self._failure(
169+
request, message="Cohort is being deleted.", code=404
170+
)
160171
# A large first sync arrives as several requests, each one page
161172
# of members. Every page only adds; removals can't be detected
162173
# without seeing all pages at once.
@@ -194,6 +205,14 @@ def handle_exception(self, exc: Exception) -> Response:
194205
# A body that isn't valid JSON raises before post() runs, so the
195206
# response is shaped here to keep the envelope Mixpanel expects.
196207
return self._failure(self.request, message="Invalid payload.", code=400)
208+
if isinstance(exc, ValidationError):
209+
# Raised below the view, e.g. by the segment limit on cohort
210+
# creation; shaped here for the same reason.
211+
return self._failure(
212+
self.request,
213+
message=json.dumps(exc.detail, default=str),
214+
code=400,
215+
)
197216
return super().handle_exception(exc)
198217

199218
def _failure(self, request: Request, *, message: str, code: int) -> Response:

api/tests/unit/cohorts/test_sync_views.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,3 +913,104 @@ def test_mixpanel_webhook__distinct_id_over_1024_bytes__returns_400_failure(
913913
assert body["status"] == "failure"
914914
assert "1024 bytes" in body["error"]["message"]
915915
assert not CohortMembership.objects.exists()
916+
917+
918+
def test_mixpanel_webhook__over_1000_members__returns_400_failure(
919+
postgres_cohort_sync_key: _KeyAndPlaintext,
920+
mixpanel_cohort: Cohort,
921+
) -> None:
922+
# Given - one more member than Mixpanel's documented batch size
923+
_, plaintext = postgres_cohort_sync_key
924+
client = _basic_auth_client(plaintext)
925+
url = reverse("api-v1:cohort-sync:mixpanel")
926+
927+
# When
928+
response = client.post(
929+
url,
930+
data={
931+
"action": "add_members",
932+
"parameters": {
933+
"mixpanel_cohort_id": "mp-42",
934+
"mixpanel_cohort_name": "Power users",
935+
"members": [{"mixpanel_distinct_id": f"user-{i}"} for i in range(1001)],
936+
},
937+
},
938+
format="json",
939+
)
940+
941+
# Then
942+
assert response.status_code == status.HTTP_400_BAD_REQUEST
943+
assert response.json()["status"] == "failure"
944+
assert not CohortMembership.objects.exists()
945+
946+
947+
def test_mixpanel_webhook__members_action_at_segment_limit__returns_400_failure(
948+
postgres_cohort_sync_key: _KeyAndPlaintext,
949+
settings: SettingsWrapper,
950+
) -> None:
951+
# Given - the project already holds as many segments as the plan allows
952+
key, plaintext = postgres_cohort_sync_key
953+
project = key.environment.project
954+
settings.EDGE_ENABLED = True
955+
project.max_segments_allowed = 1
956+
project.save()
957+
Segment.objects.create(name="existing", project=project)
958+
client = _basic_auth_client(plaintext)
959+
url = reverse("api-v1:cohort-sync:mixpanel")
960+
961+
# When
962+
response = client.post(
963+
url,
964+
data={
965+
"action": "members",
966+
"parameters": {
967+
"mixpanel_cohort_id": "mp-99",
968+
"mixpanel_cohort_name": "New cohort",
969+
"members": [{"mixpanel_distinct_id": "user-1"}],
970+
},
971+
},
972+
format="json",
973+
)
974+
975+
# Then
976+
assert response.status_code == status.HTTP_400_BAD_REQUEST
977+
body = response.json()
978+
assert body["action"] == "members"
979+
assert body["status"] == "failure"
980+
assert "maximum allowed segments" in body["error"]["message"]
981+
assert not Cohort.objects.filter(external_id="mp-99").exists()
982+
983+
984+
def test_mixpanel_webhook__members_action_while_cohort_draining__returns_404_failure(
985+
postgres_cohort_sync_key: _KeyAndPlaintext,
986+
mixpanel_cohort: Cohort,
987+
) -> None:
988+
# Given - the cohort was deleted in Flagsmith and is still draining
989+
mixpanel_cohort.deletion_requested_at = timezone.now()
990+
mixpanel_cohort.save()
991+
_, plaintext = postgres_cohort_sync_key
992+
client = _basic_auth_client(plaintext)
993+
url = reverse("api-v1:cohort-sync:mixpanel")
994+
995+
# When
996+
response = client.post(
997+
url,
998+
data={
999+
"action": "members",
1000+
"parameters": {
1001+
"mixpanel_cohort_id": "mp-42",
1002+
"mixpanel_cohort_name": "Power users",
1003+
"members": [{"mixpanel_distinct_id": "user-1"}],
1004+
},
1005+
},
1006+
format="json",
1007+
)
1008+
1009+
# Then
1010+
assert response.status_code == status.HTTP_404_NOT_FOUND
1011+
assert response.json() == {
1012+
"action": "members",
1013+
"status": "failure",
1014+
"error": {"message": "Cohort is being deleted.", "code": 404},
1015+
}
1016+
assert Cohort.objects.filter(source_type=CohortSourceType.MIXPANEL).count() == 1

docs/docs/deployment-self-hosting/observability/_events-catalogue.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Attributes:
8686
### `cohorts.cohort.deleted`
8787

8888
Logged at `info` from:
89-
- `api/cohorts/services.py:421`
89+
- `api/cohorts/services.py:437`
9090

9191
Attributes:
9292
- `cohort.id`
@@ -95,7 +95,7 @@ Attributes:
9595
### `cohorts.cohort.deletion_requested`
9696

9797
Logged at `info` from:
98-
- `api/cohorts/services.py:405`
98+
- `api/cohorts/services.py:421`
9999

100100
Attributes:
101101
- `cohort.id`
@@ -104,7 +104,7 @@ Attributes:
104104
### `cohorts.csv.synced`
105105

106106
Logged at `info` from:
107-
- `api/cohorts/services.py:377`
107+
- `api/cohorts/services.py:393`
108108

109109
Attributes:
110110
- `adds.count`
@@ -117,7 +117,7 @@ Attributes:
117117
### `cohorts.membership.adds_received`
118118

119119
Logged at `info` from:
120-
- `api/cohorts/services.py:230`
120+
- `api/cohorts/services.py:246`
121121

122122
Attributes:
123123
- `cohort.id`
@@ -155,7 +155,7 @@ Attributes:
155155
### `cohorts.membership.removals_received`
156156

157157
Logged at `info` from:
158-
- `api/cohorts/services.py:255`
158+
- `api/cohorts/services.py:271`
159159

160160
Attributes:
161161
- `cohort.id`
@@ -166,7 +166,7 @@ Attributes:
166166
### `cohorts.sync_webhook.rejected`
167167

168168
Logged at `warning` from:
169-
- `api/cohorts/sync_views.py:200`
169+
- `api/cohorts/sync_views.py:219`
170170

171171
Attributes:
172172
- `action`

0 commit comments

Comments
 (0)