Skip to content

Commit e161d04

Browse files
authored
ref(explorer): use sentry cursor pagination for runs endpoint (#102418)
1 parent a96651d commit e161d04

File tree

2 files changed

+56
-66
lines changed

2 files changed

+56
-66
lines changed
Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from __future__ import annotations
22

33
import logging
4+
from typing import Any
45

56
import orjson
67
from django.conf import settings
7-
from rest_framework import serializers
88
from rest_framework.request import Request
99
from rest_framework.response import Response
1010

@@ -13,6 +13,7 @@
1313
from sentry.api.api_publish_status import ApiPublishStatus
1414
from sentry.api.base import region_silo_endpoint
1515
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationPermission
16+
from sentry.api.paginator import GenericOffsetPaginator
1617
from sentry.models.organization import Organization
1718
from sentry.net.http import connection_from_url
1819
from sentry.seer.seer_setup import has_seer_access_with_detail
@@ -29,21 +30,6 @@ class OrganizationSeerExplorerRunsPermission(OrganizationPermission):
2930
}
3031

3132

32-
class ExplorerRunsRequestSerializer(serializers.Serializer):
33-
offset = serializers.IntegerField(required=False, allow_null=False)
34-
limit = serializers.IntegerField(required=False, allow_null=False)
35-
36-
def validate_offset(self, value: int) -> int:
37-
if value < 0:
38-
raise serializers.ValidationError("Offset must be non-negative")
39-
return value
40-
41-
def validate_limit(self, value: int) -> int:
42-
if value < 1:
43-
raise serializers.ValidationError("Limit must be greater than 0")
44-
return value
45-
46-
4733
@region_silo_endpoint
4834
class OrganizationSeerExplorerRunsEndpoint(OrganizationEndpoint):
4935
publish_status = {
@@ -72,34 +58,26 @@ def get(self, request: Request, organization: Organization) -> Response:
7258
status=403,
7359
)
7460

75-
serializer = ExplorerRunsRequestSerializer(data=request.GET)
76-
if not serializer.is_valid():
77-
return Response(serializer.errors, status=400)
78-
79-
offset: int | None = serializer.validated_data.get("offset")
80-
limit: int | None = serializer.validated_data.get("limit")
81-
82-
path = "/v1/automation/explorer/runs"
83-
body = orjson.dumps(
84-
{
85-
"organization_id": organization.id,
86-
"user_id": request.user.id,
87-
"offset": offset,
88-
"limit": limit,
89-
},
90-
option=orjson.OPT_NON_STR_KEYS,
91-
)
92-
93-
response = make_signed_seer_api_request(autofix_connection_pool, path, body)
94-
if response.status < 200 or response.status >= 300:
95-
logger.error(
96-
"Seer explorer runs endpoint failed",
97-
extra={
98-
"path": path,
99-
"status_code": response.status,
100-
"response_data": response.data,
61+
def make_seer_runs_request(offset: int, limit: int) -> dict[str, Any]:
62+
path = "/v1/automation/explorer/runs"
63+
body = orjson.dumps(
64+
{
65+
"organization_id": organization.id,
66+
"user_id": request.user.id,
67+
"offset": offset,
68+
"limit": limit,
10169
},
70+
option=orjson.OPT_NON_STR_KEYS,
10271
)
103-
return Response({"detail": "Internal Server Error"}, status=502)
10472

105-
return Response(response.json(), status=response.status)
73+
response = make_signed_seer_api_request(autofix_connection_pool, path, body)
74+
if response.status < 200 or response.status >= 300:
75+
raise Exception(f"Seer explorer runs endpoint failed with status {response.status}")
76+
77+
return response.json()
78+
79+
return self.paginate(
80+
request=request,
81+
paginator=GenericOffsetPaginator(data_fn=make_seer_runs_request),
82+
default_per_page=100,
83+
)

tests/sentry/seer/endpoints/test_organization_seer_explorer_runs.py

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from sentry.testutils.cases import APITestCase
77
from sentry.testutils.helpers.features import with_feature
8+
from sentry.utils.cursors import Cursor
89

910

1011
@with_feature("organizations:seer-explorer")
@@ -35,11 +36,11 @@ def tearDown(self) -> None:
3536
def test_get_simple(self) -> None:
3637
self.make_seer_request.return_value.status = 200
3738
self.make_seer_request.return_value.json.return_value = {
38-
"hello": "world",
39+
"data": [{"run_id": "1"}, {"run_id": "2"}],
3940
}
4041
response = self.client.get(self.url)
4142
assert response.status_code == 200
42-
assert response.json() == {"hello": "world"}
43+
assert response.json()["data"] == [{"run_id": "1"}, {"run_id": "2"}]
4344

4445
self.make_seer_request.assert_called_once()
4546
call_args = self.make_seer_request.call_args
@@ -48,18 +49,21 @@ def test_get_simple(self) -> None:
4849
assert body_json == {
4950
"organization_id": self.organization.id,
5051
"user_id": self.user.id,
51-
"limit": None,
52-
"offset": None,
52+
"limit": 101, # Default per_page of 100 + 1 for has_more
53+
"offset": 0,
5354
}
5455

55-
def test_get_with_offset_and_limit(self) -> None:
56+
def test_get_cursor_pagination(self) -> None:
5657
self.make_seer_request.return_value.status = 200
58+
# Mock seer response for offset 0, limit 3.
5759
self.make_seer_request.return_value.json.return_value = {
58-
"hello": "world",
60+
"data": [{"run_id": "1"}, {"run_id": "2"}, {"run_id": "3"}],
5961
}
60-
response = self.client.get(self.url + "?offset=1&limit=11")
62+
cursor = str(Cursor(0, 0))
63+
response = self.client.get(self.url + f"?per_page=2&cursor={cursor}")
6164
assert response.status_code == 200
62-
assert response.json() == {"hello": "world"}
65+
assert response.json()["data"] == [{"run_id": "1"}, {"run_id": "2"}]
66+
assert 'rel="next"; results="true"' in response.headers["Link"]
6367

6468
self.make_seer_request.assert_called_once()
6569
call_args = self.make_seer_request.call_args
@@ -68,26 +72,34 @@ def test_get_with_offset_and_limit(self) -> None:
6872
assert body_json == {
6973
"organization_id": self.organization.id,
7074
"user_id": self.user.id,
71-
"limit": 11,
72-
"offset": 1,
75+
"limit": 3, # +1 for has_more
76+
"offset": 0,
7377
}
7478

75-
def test_get_with_invalid_limit(self) -> None:
76-
for value in ["invalid", "-1", "0"]:
77-
response = self.client.get(self.url + f"?limit={value}")
78-
assert response.status_code == 400
79-
assert "limit" in response.json()
79+
# Second page - mock seer response for offset 2, limit 3.
80+
self.make_seer_request.return_value.json.return_value = {
81+
"data": [{"run_id": "3"}, {"run_id": "4"}],
82+
}
83+
cursor = str(Cursor(0, 2))
84+
response = self.client.get(self.url + f"?per_page=2&cursor={cursor}")
85+
assert response.status_code == 200
86+
assert response.json()["data"] == [{"run_id": "3"}, {"run_id": "4"}]
87+
assert 'rel="next"; results="false"' in response.headers["Link"]
8088

81-
def test_get_with_invalid_offset(self) -> None:
82-
for value in ["invalid", "-1"]:
83-
response = self.client.get(self.url + f"?offset={value}")
84-
assert response.status_code == 400
85-
assert "offset" in response.json()
89+
call_args = self.make_seer_request.call_args
90+
assert call_args[0][1] == "/v1/automation/explorer/runs"
91+
body_json = orjson.loads(call_args[0][2])
92+
assert body_json == {
93+
"organization_id": self.organization.id,
94+
"user_id": self.user.id,
95+
"limit": 3, # +1 for has_more
96+
"offset": 2,
97+
}
8698

8799
def test_get_with_seer_error(self) -> None:
88-
self.make_seer_request.return_value.status = 500
100+
self.make_seer_request.return_value.status = 404
89101
response = self.client.get(self.url)
90-
assert response.status_code == 502
102+
assert response.status_code == 500
91103

92104

93105
class TestOrganizationSeerExplorerRunsEndpointFeatureFlags(APITestCase):

0 commit comments

Comments
 (0)