Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 33 additions & 27 deletions src/sentry/uptime/endpoints/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
MaxManualUptimeSubscriptionsReached,
MaxUrlsForDomainReachedException,
UptimeMonitorNoSeatAvailable,
check_uptime_subscription_limit,
check_url_limits,
create_uptime_detector,
create_uptime_subscription,
Expand Down Expand Up @@ -202,6 +203,16 @@ class UptimeMonitorValidator(CamelSnakeSerializer):
)

def validate(self, attrs):
# When creating a new uptime monitor, check if we would exceed the organization limit
if not self.instance:
organization = self.context["organization"]
try:
check_uptime_subscription_limit(organization.id)
except MaxManualUptimeSubscriptionsReached:
raise serializers.ValidationError(
f"You may have at most {MAX_MANUAL_SUBSCRIPTIONS_PER_ORG} uptime monitors per organization"
)

headers = []
method = "GET"
body = None
Expand Down Expand Up @@ -246,34 +257,29 @@ def create(self, validated_data):
method_headers_body = {
k: v for k, v in validated_data.items() if k in {"method", "headers", "body"}
}
try:
detector = create_uptime_detector(
project=self.context["project"],
environment=environment,
url=validated_data["url"],
interval_seconds=validated_data["interval_seconds"],
timeout_ms=validated_data["timeout_ms"],
name=validated_data["name"],
status=validated_data.get("status"),
mode=validated_data.get("mode", UptimeMonitorMode.MANUAL),
owner=validated_data.get("owner"),
trace_sampling=validated_data.get("trace_sampling", False),
recovery_threshold=validated_data["recovery_threshold"],
downtime_threshold=validated_data["downtime_threshold"],
**method_headers_body,
)
detector = create_uptime_detector(
project=self.context["project"],
environment=environment,
url=validated_data["url"],
interval_seconds=validated_data["interval_seconds"],
timeout_ms=validated_data["timeout_ms"],
name=validated_data["name"],
status=validated_data.get("status"),
mode=validated_data.get("mode", UptimeMonitorMode.MANUAL),
owner=validated_data.get("owner"),
trace_sampling=validated_data.get("trace_sampling", False),
recovery_threshold=validated_data["recovery_threshold"],
downtime_threshold=validated_data["downtime_threshold"],
**method_headers_body,
)

create_audit_entry(
request=self.context["request"],
organization=self.context["organization"],
target_object=detector.id,
event=audit_log.get_event_id("UPTIME_MONITOR_ADD"),
data=get_audit_log_data(detector),
)
except MaxManualUptimeSubscriptionsReached:
raise serializers.ValidationError(
f"You may have at most {MAX_MANUAL_SUBSCRIPTIONS_PER_ORG} uptime monitors per organization"
)
create_audit_entry(
request=self.context["request"],
organization=self.context["organization"],
target_object=detector.id,
event=audit_log.get_event_id("UPTIME_MONITOR_ADD"),
data=get_audit_log_data(detector),
)

Comment on lines +279 to 283
Copy link
Contributor

Choose a reason for hiding this comment

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

Potential bug: The create method no longer handles the MaxManualUptimeSubscriptionsReached exception, which can be raised during a race condition, causing a 500 error instead of a validation error.
  • Description: The create method in UptimeMonitorValidator no longer handles the MaxManualUptimeSubscriptionsReached exception. This exception can be raised by check_uptime_subscription_limit, which is called within the creation logic. While a limit check was added to the validate method, a race condition can occur where concurrent requests cause the subscription limit to be exceeded between the validation and creation steps. Because the try...except block was removed from the create method, this race condition will lead to an unhandled exception, resulting in a 500 Internal Server Error instead of the expected 400 Bad Request with a validation error.

  • Suggested fix: Wrap the call to super().create() within the create method in a try...except MaxManualUptimeSubscriptionsReached block. In the except block, raise a ValidationError to ensure subscription limit errors are handled gracefully as 400 responses, preventing 500 errors from race conditions.
    severity: 0.65, confidence: 0.98

Did we get this right? 👍 / 👎 to inform future reviews.

return detector

Expand Down
30 changes: 18 additions & 12 deletions src/sentry/uptime/subscriptions/subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ class MaxManualUptimeSubscriptionsReached(ValueError):
pass


def check_uptime_subscription_limit(organization_id: int) -> None:
"""
Check if adding a new manual uptime monitor would exceed the organization's limit.
Raises MaxManualUptimeSubscriptionsReached if the limit would be exceeded.
"""
manual_subscription_count = Detector.objects.filter(
status=ObjectStatus.ACTIVE,
type=GROUP_TYPE_UPTIME_DOMAIN_CHECK_FAILURE,
project__organization_id=organization_id,
config__mode=UptimeMonitorMode.MANUAL,
).count()

if manual_subscription_count >= MAX_MANUAL_SUBSCRIPTIONS_PER_ORG:
raise MaxManualUptimeSubscriptionsReached


class UptimeMonitorNoSeatAvailable(Exception):
"""
Indicates that the quotes system is unable to allocate a seat for the new
Expand Down Expand Up @@ -234,13 +250,6 @@ def create_uptime_detector(
Creates an UptimeSubscription and associated Detector
"""
if mode == UptimeMonitorMode.MANUAL:
manual_subscription_count = Detector.objects.filter(
status=ObjectStatus.ACTIVE,
type=GROUP_TYPE_UPTIME_DOMAIN_CHECK_FAILURE,
project__organization=project.organization,
config__mode=UptimeMonitorMode.MANUAL,
).count()

# Once a user has created a subscription manually, make sure we disable all autodetection, and remove any
# onboarding monitors
if project.organization.get_option("sentry:uptime_autodetection", False):
Expand All @@ -250,11 +259,8 @@ def create_uptime_detector(
):
delete_uptime_detector(detector)

if (
not override_manual_org_limit
and manual_subscription_count >= MAX_MANUAL_SUBSCRIPTIONS_PER_ORG
):
raise MaxManualUptimeSubscriptionsReached
if not override_manual_org_limit:
check_uptime_subscription_limit(project.organization_id)

with atomic_transaction(
using=(
Expand Down
Loading