From cee477f0fe9ee1c25de4b6acfad100bf52dfce7d Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Fri, 13 Feb 2026 10:18:40 -0800 Subject: [PATCH 1/4] ref(seer): Migrate remaining seer integrations to urllib3 Replace requests.post with make_signed_seer_api_request in: - supergroups.py: Supergroup embeddings - trace_summary.py: Trace summarization - tasks/seer.py & tasks/seer_explorer_index.py: Background tasks - api/endpoints/seer_models.py: Seer models API Update corresponding tests with new mocking patterns. This completes the migration from requests to urllib3 connection pools across all Seer integration code. Co-Authored-By: Claude --- src/sentry/api/endpoints/seer_models.py | 25 ++++++++++--------- src/sentry/seer/supergroups.py | 21 ++++++++-------- src/sentry/seer/trace_summary.py | 21 ++++++++-------- src/sentry/tasks/seer.py | 21 ++++++++-------- src/sentry/tasks/seer_explorer_index.py | 23 ++++++++--------- tests/sentry/seer/test_supergroups.py | 20 +++++++-------- tests/sentry/seer/test_test_generation.py | 2 +- .../sentry/tasks/test_seer_explorer_index.py | 4 +-- 8 files changed, 67 insertions(+), 70 deletions(-) diff --git a/src/sentry/api/endpoints/seer_models.py b/src/sentry/api/endpoints/seer_models.py index de9cca17e784df..18dd6dab8e9d01 100644 --- a/src/sentry/api/endpoints/seer_models.py +++ b/src/sentry/api/endpoints/seer_models.py @@ -3,8 +3,6 @@ import logging from typing import TypedDict -import requests -from django.conf import settings from drf_spectacular.utils import extend_schema from rest_framework.exceptions import APIException from rest_framework.request import Request @@ -15,7 +13,10 @@ from sentry.api.base import Endpoint, region_silo_endpoint from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.ratelimits.config import RateLimitConfig -from sentry.seer.signed_seer_api import sign_with_seer_secret +from sentry.seer.signed_seer_api import ( + make_signed_seer_api_request, + seer_autofix_default_connection_pool, +) from sentry.types.ratelimit import RateLimit, RateLimitCategory from sentry.utils import json from sentry.utils.cache import cache @@ -84,23 +85,23 @@ def get(self, request: Request) -> Response: path = "/v1/models" try: - response = requests.get( - f"{settings.SEER_AUTOFIX_URL}{path}", - headers={ - "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret(b""), - }, + response = make_signed_seer_api_request( + seer_autofix_default_connection_pool, + path, + b"", timeout=5, + method="GET", ) - response.raise_for_status() + if response.status >= 400: + raise Exception(f"Seer request failed with status {response.status}") data = response.json() cache.set(SEER_MODELS_CACHE_KEY, data, SEER_MODELS_CACHE_TIMEOUT) return Response(data, status=200) - except requests.exceptions.Timeout: + except TimeoutError: logger.warning("Timeout when fetching models from Seer") raise SeerTimeoutError() - except (requests.exceptions.RequestException, json.JSONDecodeError): + except (Exception, json.JSONDecodeError): logger.exception("Error fetching models from Seer") raise SeerConnectionError() diff --git a/src/sentry/seer/supergroups.py b/src/sentry/seer/supergroups.py index 5ceb093778e2d7..1a4c84c0517553 100644 --- a/src/sentry/seer/supergroups.py +++ b/src/sentry/seer/supergroups.py @@ -1,10 +1,11 @@ from __future__ import annotations import orjson -import requests -from django.conf import settings -from sentry.seer.signed_seer_api import sign_with_seer_secret +from sentry.seer.signed_seer_api import ( + make_signed_seer_api_request, + seer_autofix_default_connection_pool, +) def trigger_supergroups_embedding( @@ -21,13 +22,11 @@ def trigger_supergroups_embedding( } ) - response = requests.post( - f"{settings.SEER_AUTOFIX_URL}{path}", - data=body, - headers={ - "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret(body), - }, + response = make_signed_seer_api_request( + seer_autofix_default_connection_pool, + path, + body, timeout=5, ) - response.raise_for_status() + if response.status >= 400: + raise Exception(f"Seer request failed with status {response.status}") diff --git a/src/sentry/seer/trace_summary.py b/src/sentry/seer/trace_summary.py index e3a405807d5937..d1c434d7ceaf95 100644 --- a/src/sentry/seer/trace_summary.py +++ b/src/sentry/seer/trace_summary.py @@ -3,15 +3,16 @@ from typing import Any import orjson -import requests -from django.conf import settings from django.contrib.auth.models import AnonymousUser from sentry import features from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case from sentry.models.organization import Organization from sentry.seer.models import SummarizeTraceResponse -from sentry.seer.signed_seer_api import sign_with_seer_secret +from sentry.seer.signed_seer_api import ( + make_signed_seer_api_request, + seer_summarization_default_connection_pool, +) from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser from sentry.utils.cache import cache @@ -79,14 +80,12 @@ def _call_seer( option=orjson.OPT_NON_STR_KEYS, ) - response = requests.post( - f"{settings.SEER_SUMMARIZATION_URL}{path}", - data=body, - headers={ - "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret(body), - }, + response = make_signed_seer_api_request( + seer_summarization_default_connection_pool, + path, + body, ) - response.raise_for_status() + if response.status >= 400: + raise Exception(f"Seer request failed with status {response.status}") return SummarizeTraceResponse.validate(response.json()) diff --git a/src/sentry/tasks/seer.py b/src/sentry/tasks/seer.py index a93ec28235ebed..5f8b24464c3db7 100644 --- a/src/sentry/tasks/seer.py +++ b/src/sentry/tasks/seer.py @@ -3,10 +3,11 @@ import logging import orjson -import requests -from django.conf import settings -from sentry.seer.signed_seer_api import sign_with_seer_secret +from sentry.seer.signed_seer_api import ( + make_signed_seer_api_request, + seer_autofix_default_connection_pool, +) from sentry.silo.base import SiloMode from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import seer_tasks @@ -40,15 +41,13 @@ def cleanup_seer_repository_preferences( ) try: - response = requests.post( - f"{settings.SEER_AUTOFIX_URL}{path}", - data=body, - headers={ - "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret(body), - }, + response = make_signed_seer_api_request( + seer_autofix_default_connection_pool, + path, + body, ) - response.raise_for_status() + if response.status >= 400: + raise Exception(f"Seer request failed with status {response.status}") logger.info( "cleanup_seer_repository_preferences.success", extra={ diff --git a/src/sentry/tasks/seer_explorer_index.py b/src/sentry/tasks/seer_explorer_index.py index 5cb7f73259d389..198ff808ded379 100644 --- a/src/sentry/tasks/seer_explorer_index.py +++ b/src/sentry/tasks/seer_explorer_index.py @@ -5,16 +5,17 @@ from datetime import datetime, timedelta import orjson -import requests import sentry_sdk -from django.conf import settings from django.utils import timezone as django_timezone from sentry import features, options from sentry.constants import ObjectStatus from sentry.models.project import Project from sentry.options.rollout import in_rollout_group -from sentry.seer.signed_seer_api import sign_with_seer_secret +from sentry.seer.signed_seer_api import ( + make_signed_seer_api_request, + seer_autofix_default_connection_pool, +) from sentry.tasks.base import instrumented_task from sentry.tasks.statistical_detectors import compute_delay from sentry.taskworker.namespaces import seer_tasks @@ -227,17 +228,15 @@ def run_explorer_index_for_projects( path = "/v1/automation/explorer/index" try: - response = requests.post( - f"{settings.SEER_AUTOFIX_URL}{path}", - data=body, - headers={ - "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret(body), - }, + response = make_signed_seer_api_request( + seer_autofix_default_connection_pool, + path, + body, timeout=30, ) - response.raise_for_status() - except requests.RequestException as e: + if response.status >= 400: + raise Exception(f"Seer request failed with status {response.status}") + except Exception as e: logger.exception( "Failed to schedule explorer index tasks in seer", extra={ diff --git a/tests/sentry/seer/test_supergroups.py b/tests/sentry/seer/test_supergroups.py index 9f60f9b3b17614..e2b92bbaa7ee5f 100644 --- a/tests/sentry/seer/test_supergroups.py +++ b/tests/sentry/seer/test_supergroups.py @@ -1,17 +1,15 @@ from unittest.mock import patch import orjson -from django.conf import settings from sentry.seer.supergroups import trigger_supergroups_embedding from sentry.testutils.cases import TestCase class TriggerSupergroupsEmbeddingTest(TestCase): - @patch("sentry.seer.supergroups.requests.post") - @patch("sentry.seer.supergroups.sign_with_seer_secret", return_value={}) - def test_calls_seer_with_correct_payload(self, mock_sign, mock_post): - mock_post.return_value.raise_for_status.return_value = None + @patch("sentry.seer.supergroups.make_signed_seer_api_request") + def test_calls_seer_with_correct_payload(self, mock_request): + mock_request.return_value.status = 200 trigger_supergroups_embedding( organization_id=1, @@ -19,12 +17,14 @@ def test_calls_seer_with_correct_payload(self, mock_sign, mock_post): artifact_data={"one_line_description": "Null pointer in auth module"}, ) - mock_post.assert_called_once() - assert mock_post.call_args.args[0] == f"{settings.SEER_AUTOFIX_URL}/v0/issues/supergroups" - assert mock_post.call_args.kwargs["timeout"] == 5 + mock_request.assert_called_once() + # First arg is connection pool, second is path + call_args = mock_request.call_args + assert "/v0/issues/supergroups" in call_args.args[1] + assert call_args.kwargs["timeout"] == 5 - mock_sign.assert_called_once() - payload = orjson.loads(mock_sign.call_args.args[0]) + # Third arg is the body (payload) + payload = orjson.loads(call_args.args[2]) assert payload["organization_id"] == 1 assert payload["group_id"] == 123 assert payload["artifact_data"] == {"one_line_description": "Null pointer in auth module"} diff --git a/tests/sentry/seer/test_test_generation.py b/tests/sentry/seer/test_test_generation.py index 30212ca353cdb0..12629fe0ac8e66 100644 --- a/tests/sentry/seer/test_test_generation.py +++ b/tests/sentry/seer/test_test_generation.py @@ -8,7 +8,7 @@ from sentry.testutils.silo import control_silo_test -@patch("sentry.seer.services.test_generation.impl.requests.post") +@patch("sentry.seer.services.test_generation.impl.make_signed_seer_api_request") @django_db_all @control_silo_test def test_start_unit_test_generation(posts_mock: MagicMock) -> None: diff --git a/tests/sentry/tasks/test_seer_explorer_index.py b/tests/sentry/tasks/test_seer_explorer_index.py index f81270792c6499..a17b89e6fb9f1c 100644 --- a/tests/sentry/tasks/test_seer_explorer_index.py +++ b/tests/sentry/tasks/test_seer_explorer_index.py @@ -452,6 +452,6 @@ def test_handles_request_error(self): def test_skips_when_option_disabled(self): with self.options({"seer.explorer_index.enable": False}): - with mock.patch("sentry.tasks.seer_explorer_index.requests.post") as mock_post: + with mock.patch("sentry.tasks.seer_explorer_index.requests.post") as mock_request: run_explorer_index_for_projects([(1, 100)], "2024-01-15T12:00:00+00:00") - mock_post.assert_not_called() + mock_request.assert_not_called() From a7928505b0578233bf6cc6748d9d20ed6c46c34d Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Tue, 24 Feb 2026 15:14:42 -0800 Subject: [PATCH 2/4] ref(seer): Migrate remaining seer calls to urllib3 connection pools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all remaining `requests.post` + `sign_with_seer_secret` calls to `make_signed_seer_api_request` with urllib3 connection pools, and replace bare `Exception` raises with `SeerApiError` across all cherry-picked files. Migrated callers: - explorer_context_engine_tasks (requests.post → connection pool) - uptime/seer_assertions (requests.post → connection pool) - seer/services/test_generation/impl (requests.post → connection pool) SeerApiError cleanup: - seer_models, supergroups, trace_summary, seer tasks, seer_explorer_index After this change, no external `requests.post` or `sign_with_seer_secret` calls remain outside of `make_signed_seer_api_request`. Co-Authored-By: Claude --- src/sentry/api/endpoints/seer_models.py | 3 +- .../seer/services/test_generation/impl.py | 21 ++++---- src/sentry/seer/supergroups.py | 3 +- src/sentry/seer/trace_summary.py | 4 +- .../tasks/explorer_context_engine_tasks.py | 24 +++++----- src/sentry/tasks/seer.py | 3 +- src/sentry/tasks/seer_explorer_index.py | 3 +- src/sentry/uptime/seer_assertions.py | 24 +++++----- tests/sentry/seer/test_test_generation.py | 13 ++--- .../test_explorer_context_engine_tasks.py | 48 ++++++------------- .../sentry/tasks/test_seer_explorer_index.py | 39 +++++++-------- tests/sentry/uptime/test_seer_assertions.py | 26 +++++----- 12 files changed, 91 insertions(+), 120 deletions(-) diff --git a/src/sentry/api/endpoints/seer_models.py b/src/sentry/api/endpoints/seer_models.py index 18dd6dab8e9d01..9e23b4fdfb0fe5 100644 --- a/src/sentry/api/endpoints/seer_models.py +++ b/src/sentry/api/endpoints/seer_models.py @@ -13,6 +13,7 @@ from sentry.api.base import Endpoint, region_silo_endpoint from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.ratelimits.config import RateLimitConfig +from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( make_signed_seer_api_request, seer_autofix_default_connection_pool, @@ -93,7 +94,7 @@ def get(self, request: Request) -> Response: method="GET", ) if response.status >= 400: - raise Exception(f"Seer request failed with status {response.status}") + raise SeerApiError("Seer request failed", response.status) data = response.json() cache.set(SEER_MODELS_CACHE_KEY, data, SEER_MODELS_CACHE_TIMEOUT) diff --git a/src/sentry/seer/services/test_generation/impl.py b/src/sentry/seer/services/test_generation/impl.py index 06dd0bfe6c3510..d86b323904b621 100644 --- a/src/sentry/seer/services/test_generation/impl.py +++ b/src/sentry/seer/services/test_generation/impl.py @@ -1,17 +1,18 @@ import orjson -import requests -from django.conf import settings from sentry.integrations.types import IntegrationProviderSlug from sentry.seer.services.test_generation.model import CreateUnitTestResponse from sentry.seer.services.test_generation.service import TestGenerationService +from sentry.seer.signed_seer_api import ( + make_signed_seer_api_request, + seer_autofix_default_connection_pool, +) class RegionBackedTestGenerationService(TestGenerationService): def start_unit_test_generation( self, *, region_name: str, github_org: str, repo: str, pr_id: int, external_id: str ) -> CreateUnitTestResponse: - url = f"{settings.SEER_AUTOFIX_URL}/v1/automation/codegen/unit-tests" body = orjson.dumps( { "repo": { @@ -25,15 +26,13 @@ def start_unit_test_generation( option=orjson.OPT_NON_STR_KEYS, ) - response = requests.post( - url, - data=body, - headers={ - "content-type": "application/json;charset=utf-8", - }, + response = make_signed_seer_api_request( + seer_autofix_default_connection_pool, + "/v1/automation/codegen/unit-tests", + body, ) - if response.status_code == 200: + if response.status == 200: return CreateUnitTestResponse() else: - return CreateUnitTestResponse(error_detail=response.text) + return CreateUnitTestResponse(error_detail=response.data.decode("utf-8")) diff --git a/src/sentry/seer/supergroups.py b/src/sentry/seer/supergroups.py index 1a4c84c0517553..49f03a6f732159 100644 --- a/src/sentry/seer/supergroups.py +++ b/src/sentry/seer/supergroups.py @@ -2,6 +2,7 @@ import orjson +from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( make_signed_seer_api_request, seer_autofix_default_connection_pool, @@ -29,4 +30,4 @@ def trigger_supergroups_embedding( timeout=5, ) if response.status >= 400: - raise Exception(f"Seer request failed with status {response.status}") + raise SeerApiError("Seer request failed", response.status) diff --git a/src/sentry/seer/trace_summary.py b/src/sentry/seer/trace_summary.py index d1c434d7ceaf95..b6bde68f09ae38 100644 --- a/src/sentry/seer/trace_summary.py +++ b/src/sentry/seer/trace_summary.py @@ -8,7 +8,7 @@ from sentry import features from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case from sentry.models.organization import Organization -from sentry.seer.models import SummarizeTraceResponse +from sentry.seer.models import SeerApiError, SummarizeTraceResponse from sentry.seer.signed_seer_api import ( make_signed_seer_api_request, seer_summarization_default_connection_pool, @@ -86,6 +86,6 @@ def _call_seer( body, ) if response.status >= 400: - raise Exception(f"Seer request failed with status {response.status}") + raise SeerApiError("Seer request failed", response.status) return SummarizeTraceResponse.validate(response.json()) diff --git a/src/sentry/tasks/explorer_context_engine_tasks.py b/src/sentry/tasks/explorer_context_engine_tasks.py index 6f92ff114cc44d..4610d778a06d62 100644 --- a/src/sentry/tasks/explorer_context_engine_tasks.py +++ b/src/sentry/tasks/explorer_context_engine_tasks.py @@ -4,9 +4,7 @@ from datetime import UTC, datetime, timedelta, timezone import orjson -import requests import sentry_sdk -from django.conf import settings from sentry import options from sentry.constants import ObjectStatus @@ -27,7 +25,11 @@ _query_service_dependencies, _send_to_seer, ) -from sentry.seer.signed_seer_api import sign_with_seer_secret +from sentry.seer.models import SeerApiError +from sentry.seer.signed_seer_api import ( + make_signed_seer_api_request, + seer_autofix_default_connection_pool, +) from sentry.tasks.base import instrumented_task from sentry.taskworker.namespaces import seer_tasks @@ -103,17 +105,15 @@ def index_org_project_knowledge(org_id: int) -> None: path = "/v1/automation/explorer/index/org-project-knowledge" try: - response = requests.post( - f"{settings.SEER_AUTOFIX_URL}{path}", - data=body, - headers={ - "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret(body), - }, + response = make_signed_seer_api_request( + seer_autofix_default_connection_pool, + path, + body, timeout=30, ) - response.raise_for_status() - except requests.RequestException: + if response.status >= 400: + raise SeerApiError("Seer request failed", response.status) + except Exception: logger.exception( "Failed to call Seer org-project-knowledge endpoint", extra={"org_id": org_id, "num_projects": len(project_data)}, diff --git a/src/sentry/tasks/seer.py b/src/sentry/tasks/seer.py index 5f8b24464c3db7..ed0111d44d0c13 100644 --- a/src/sentry/tasks/seer.py +++ b/src/sentry/tasks/seer.py @@ -4,6 +4,7 @@ import orjson +from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( make_signed_seer_api_request, seer_autofix_default_connection_pool, @@ -47,7 +48,7 @@ def cleanup_seer_repository_preferences( body, ) if response.status >= 400: - raise Exception(f"Seer request failed with status {response.status}") + raise SeerApiError("Seer request failed", response.status) logger.info( "cleanup_seer_repository_preferences.success", extra={ diff --git a/src/sentry/tasks/seer_explorer_index.py b/src/sentry/tasks/seer_explorer_index.py index 198ff808ded379..a0320abb657ab1 100644 --- a/src/sentry/tasks/seer_explorer_index.py +++ b/src/sentry/tasks/seer_explorer_index.py @@ -12,6 +12,7 @@ from sentry.constants import ObjectStatus from sentry.models.project import Project from sentry.options.rollout import in_rollout_group +from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( make_signed_seer_api_request, seer_autofix_default_connection_pool, @@ -235,7 +236,7 @@ def run_explorer_index_for_projects( timeout=30, ) if response.status >= 400: - raise Exception(f"Seer request failed with status {response.status}") + raise SeerApiError("Seer request failed", response.status) except Exception as e: logger.exception( "Failed to schedule explorer index tasks in seer", diff --git a/src/sentry/uptime/seer_assertions.py b/src/sentry/uptime/seer_assertions.py index cad5cd6d08fa7e..68851381bfcfd0 100644 --- a/src/sentry/uptime/seer_assertions.py +++ b/src/sentry/uptime/seer_assertions.py @@ -10,11 +10,13 @@ from typing import Any import orjson -import requests -from django.conf import settings from pydantic import BaseModel, Field -from sentry.seer.signed_seer_api import sign_with_seer_secret +from sentry.seer.models import SeerApiError +from sentry.seer.signed_seer_api import ( + make_signed_seer_api_request, + seer_autofix_default_connection_pool, +) from sentry.utils import json logger = logging.getLogger(__name__) @@ -351,17 +353,15 @@ def generate_assertion_suggestions( ) try: - response = requests.post( - f"{settings.SEER_AUTOFIX_URL}/v1/llm/generate", - data=body, - headers={ - "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret(body), - }, + response = make_signed_seer_api_request( + seer_autofix_default_connection_pool, + "/v1/llm/generate", + body, timeout=30, ) - response.raise_for_status() - except requests.RequestException as e: + if response.status >= 400: + raise SeerApiError("Seer request failed", response.status) + except SeerApiError as e: logger.exception("Failed to call Seer LLM proxy") return None, f"Seer LLM proxy request failed: {e}" diff --git a/tests/sentry/seer/test_test_generation.py b/tests/sentry/seer/test_test_generation.py index 12629fe0ac8e66..03927ccbd0ccb4 100644 --- a/tests/sentry/seer/test_test_generation.py +++ b/tests/sentry/seer/test_test_generation.py @@ -1,6 +1,5 @@ -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch -import requests from django.conf import settings from sentry.seer.services.test_generation.service import test_generation_service @@ -11,11 +10,9 @@ @patch("sentry.seer.services.test_generation.impl.make_signed_seer_api_request") @django_db_all @control_silo_test -def test_start_unit_test_generation(posts_mock: MagicMock) -> None: - response_object: requests.Response = requests.Response() - response_object.json = Mock(method="json", return_value={}) # type: ignore[method-assign] - response_object.status_code = 200 - posts_mock.return_value = response_object +def test_start_unit_test_generation(mock_request: MagicMock) -> None: + mock_request.return_value.status = 200 + mock_request.return_value.json.return_value = {} response = test_generation_service.start_unit_test_generation( region_name=settings.SENTRY_MONOLITH_REGION, github_org="some-org", @@ -25,4 +22,4 @@ def test_start_unit_test_generation(posts_mock: MagicMock) -> None: ) assert response.success - posts_mock.assert_called_once() + mock_request.assert_called_once() diff --git a/tests/sentry/tasks/test_explorer_context_engine_tasks.py b/tests/sentry/tasks/test_explorer_context_engine_tasks.py index c5020aa81f2fb0..dd1f20ea3109cf 100644 --- a/tests/sentry/tasks/test_explorer_context_engine_tasks.py +++ b/tests/sentry/tasks/test_explorer_context_engine_tasks.py @@ -2,8 +2,6 @@ import orjson import pytest -import responses -from django.conf import settings from sentry.seer.explorer.context_engine_utils import ProjectEventCounts from sentry.tasks.explorer_context_engine_tasks import ( @@ -41,19 +39,14 @@ def test_returns_early_when_no_high_volume_projects(self): return_value={}, ): with mock.patch( - "sentry.tasks.explorer_context_engine_tasks.requests.post" - ) as mock_post: + "sentry.tasks.explorer_context_engine_tasks.make_signed_seer_api_request" + ) as mock_request: index_org_project_knowledge(self.org.id) - mock_post.assert_not_called() + mock_request.assert_not_called() - @responses.activate - def test_calls_seer_endpoint_with_correct_payload(self): - responses.add( - responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/explorer/index/org-project-knowledge", - json={"scheduled": True}, - status=200, - ) + @mock.patch("sentry.tasks.explorer_context_engine_tasks.make_signed_seer_api_request") + def test_calls_seer_endpoint_with_correct_payload(self, mock_request): + mock_request.return_value.status = 200 event_counts = { self.project.id: ProjectEventCounts(error_count=5000, transaction_count=2000) @@ -76,14 +69,10 @@ def test_calls_seer_endpoint_with_correct_payload(self): "sentry.tasks.explorer_context_engine_tasks.get_sdk_names_for_org_projects", return_value={self.project.id: "sentry.python"}, ): - with mock.patch( - "sentry.tasks.explorer_context_engine_tasks.sign_with_seer_secret", - return_value={}, - ): - index_org_project_knowledge(self.org.id) + index_org_project_knowledge(self.org.id) - assert len(responses.calls) == 1 - body = orjson.loads(responses.calls[0].request.body) + mock_request.assert_called_once() + body = orjson.loads(mock_request.call_args[0][2]) assert body["org_id"] == self.org.id assert len(body["projects"]) == 1 @@ -98,14 +87,9 @@ def test_calls_seer_endpoint_with_correct_payload(self): assert project_payload["top_transactions"] == ["GET /api/0/projects/"] assert project_payload["top_span_operations"] == [["db", "SELECT * FROM table"]] - @responses.activate - def test_raises_on_seer_error(self): - responses.add( - responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/explorer/index/org-project-knowledge", - json={"error": "Internal server error"}, - status=500, - ) + @mock.patch("sentry.tasks.explorer_context_engine_tasks.make_signed_seer_api_request") + def test_raises_on_seer_error(self, mock_request): + mock_request.return_value.status = 500 with override_options({"explorer.context_engine_indexing.enable": True}): with mock.patch( @@ -126,12 +110,8 @@ def test_raises_on_seer_error(self): "sentry.tasks.explorer_context_engine_tasks.get_sdk_names_for_org_projects", return_value={}, ): - with mock.patch( - "sentry.tasks.explorer_context_engine_tasks.sign_with_seer_secret", - return_value={}, - ): - with pytest.raises(Exception): - index_org_project_knowledge(self.org.id) + with pytest.raises(Exception): + index_org_project_knowledge(self.org.id) @django_db_all diff --git a/tests/sentry/tasks/test_seer_explorer_index.py b/tests/sentry/tasks/test_seer_explorer_index.py index a17b89e6fb9f1c..277ab42003de18 100644 --- a/tests/sentry/tasks/test_seer_explorer_index.py +++ b/tests/sentry/tasks/test_seer_explorer_index.py @@ -1,10 +1,9 @@ from datetime import UTC, datetime from unittest import mock +from unittest.mock import patch import orjson import pytest -import responses -from django.conf import settings from sentry.constants import ObjectStatus from sentry.models.promptsactivity import PromptsActivity @@ -412,46 +411,40 @@ def test_batches_projects_with_delays(self): @django_db_all class TestRunExplorerIndexForProjects(TestCase): - @responses.activate - def test_calls_seer_endpoint_successfully(self): + @patch("sentry.tasks.seer_explorer_index.make_signed_seer_api_request") + def test_calls_seer_endpoint_successfully(self, mock_request): + mock_request.return_value.status = 200 + mock_request.return_value.json.return_value = {"scheduled_count": 3, "projects": []} + projects = [(1, 100), (2, 100), (3, 200)] start = "2024-01-15T12:00:00+00:00" - responses.add( - responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/explorer/index", - json={"scheduled_count": 3, "projects": []}, - status=200, - ) - with self.options({"seer.explorer_index.enable": True}): run_explorer_index_for_projects(projects, start) - request_body = orjson.loads(responses.calls[0].request.body) - assert request_body["projects"] == [ + mock_request.assert_called_once() + body = orjson.loads(mock_request.call_args[0][2]) + assert body["projects"] == [ {"org_id": 100, "project_id": 1}, {"org_id": 100, "project_id": 2}, {"org_id": 200, "project_id": 3}, ] - @responses.activate - def test_handles_request_error(self): + @patch("sentry.tasks.seer_explorer_index.make_signed_seer_api_request") + def test_handles_request_error(self, mock_request): + mock_request.return_value.status = 500 + projects = [(1, 100)] start = "2024-01-15T12:00:00+00:00" - responses.add( - responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/automation/explorer/index", - json={"error": "Internal server error"}, - status=500, - ) - with self.options({"seer.explorer_index.enable": True}): with pytest.raises(Exception): run_explorer_index_for_projects(projects, start) def test_skips_when_option_disabled(self): with self.options({"seer.explorer_index.enable": False}): - with mock.patch("sentry.tasks.seer_explorer_index.requests.post") as mock_request: + with mock.patch( + "sentry.tasks.seer_explorer_index.make_signed_seer_api_request" + ) as mock_request: run_explorer_index_for_projects([(1, 100)], "2024-01-15T12:00:00+00:00") mock_request.assert_not_called() diff --git a/tests/sentry/uptime/test_seer_assertions.py b/tests/sentry/uptime/test_seer_assertions.py index bc57dbe6523477..fcfaa7d8fe9760 100644 --- a/tests/sentry/uptime/test_seer_assertions.py +++ b/tests/sentry/uptime/test_seer_assertions.py @@ -288,11 +288,11 @@ def test_no_status_code(self): assert suggestions is None assert debug is not None and "No status_code" in debug - @patch("sentry.uptime.seer_assertions.requests.post") - def test_seer_request_failure(self, mock_post): - import requests + @patch("sentry.uptime.seer_assertions.make_signed_seer_api_request") + def test_seer_request_failure(self, mock_request): + from sentry.seer.models import SeerApiError - mock_post.side_effect = requests.RequestException("Connection failed") + mock_request.side_effect = SeerApiError("Connection failed", 500) preview_result = { "check_result": { @@ -310,11 +310,10 @@ def test_seer_request_failure(self, mock_post): assert suggestions is None assert debug is not None and "request failed" in debug - @patch("sentry.uptime.seer_assertions.requests.post") - def test_successful_generation(self, mock_post): - mock_response = mock_post.return_value - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = { + @patch("sentry.uptime.seer_assertions.make_signed_seer_api_request") + def test_successful_generation(self, mock_request): + mock_request.return_value.status = 200 + mock_request.return_value.json.return_value = { "content": '{"suggestions": [{"assertion_type": "status_code", "comparison": "equals", "expected_value": "200", "confidence": 0.95, "explanation": "test"}]}' } @@ -336,11 +335,10 @@ def test_successful_generation(self, mock_post): assert suggestions.suggestions[0].assertion_type == "status_code" assert debug is None - @patch("sentry.uptime.seer_assertions.requests.post") - def test_empty_seer_content(self, mock_post): - mock_response = mock_post.return_value - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"content": ""} + @patch("sentry.uptime.seer_assertions.make_signed_seer_api_request") + def test_empty_seer_content(self, mock_request): + mock_request.return_value.status = 200 + mock_request.return_value.json.return_value = {"content": ""} preview_result = { "check_result": { From 28102166ed52993b4183b3c5ccf40ad9a662fa4d Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Tue, 24 Feb 2026 15:22:43 -0800 Subject: [PATCH 3/4] fix(seer): Handle response.data None and urllib3 exceptions Address Warden feedback: - test_generation/impl.py: Handle response.data being None when decoding error response body, matching the pattern used in similar_issues.py and store_data.py - uptime/seer_assertions.py: Catch urllib3 exceptions (MaxRetryError, TimeoutError) in addition to SeerApiError for network failures, preserving the graceful fallback behavior from the requests era Co-Authored-By: Claude --- src/sentry/seer/services/test_generation/impl.py | 8 +++++++- src/sentry/uptime/seer_assertions.py | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/sentry/seer/services/test_generation/impl.py b/src/sentry/seer/services/test_generation/impl.py index d86b323904b621..a291b4f595c6a3 100644 --- a/src/sentry/seer/services/test_generation/impl.py +++ b/src/sentry/seer/services/test_generation/impl.py @@ -35,4 +35,10 @@ def start_unit_test_generation( if response.status == 200: return CreateUnitTestResponse() else: - return CreateUnitTestResponse(error_detail=response.data.decode("utf-8")) + try: + error_detail = response.data.decode("utf-8") if response.data else None + except (AttributeError, UnicodeDecodeError): + error_detail = None + return CreateUnitTestResponse( + error_detail=error_detail or f"Request failed with status {response.status}" + ) diff --git a/src/sentry/uptime/seer_assertions.py b/src/sentry/uptime/seer_assertions.py index 68851381bfcfd0..25ba39ae69422a 100644 --- a/src/sentry/uptime/seer_assertions.py +++ b/src/sentry/uptime/seer_assertions.py @@ -11,6 +11,8 @@ import orjson from pydantic import BaseModel, Field +from urllib3.exceptions import MaxRetryError +from urllib3.exceptions import TimeoutError as Urllib3TimeoutError from sentry.seer.models import SeerApiError from sentry.seer.signed_seer_api import ( @@ -361,7 +363,7 @@ def generate_assertion_suggestions( ) if response.status >= 400: raise SeerApiError("Seer request failed", response.status) - except SeerApiError as e: + except (SeerApiError, MaxRetryError, Urllib3TimeoutError) as e: logger.exception("Failed to call Seer LLM proxy") return None, f"Seer LLM proxy request failed: {e}" From 5cbc294f7ba5b7ed18a68b5564eeb721e74d4faf Mon Sep 17 00:00:00 2001 From: Jeremy Stanley Date: Tue, 24 Feb 2026 15:49:55 -0800 Subject: [PATCH 4/4] fix(seer): Update seer_models and seer task tests for urllib3 - seer_models.py: Simplify `except (Exception, json.JSONDecodeError)` to `except Exception:` since JSONDecodeError is a subclass. Remove unused json import. - test_seer_models.py: Replace `requests.get` mocks with `make_signed_seer_api_request` mocks (requests import was removed). - test_seer.py: Replace `@responses.activate` with `make_signed_seer_api_request` mocks (responses library doesn't intercept urllib3). Co-Authored-By: Claude --- src/sentry/api/endpoints/seer_models.py | 3 +- .../sentry/api/endpoints/test_seer_models.py | 67 +++++-------- tests/sentry/tasks/test_seer.py | 93 ++++++------------- 3 files changed, 53 insertions(+), 110 deletions(-) diff --git a/src/sentry/api/endpoints/seer_models.py b/src/sentry/api/endpoints/seer_models.py index 9e23b4fdfb0fe5..297d10693ed916 100644 --- a/src/sentry/api/endpoints/seer_models.py +++ b/src/sentry/api/endpoints/seer_models.py @@ -19,7 +19,6 @@ seer_autofix_default_connection_pool, ) from sentry.types.ratelimit import RateLimit, RateLimitCategory -from sentry.utils import json from sentry.utils.cache import cache logger = logging.getLogger(__name__) @@ -103,6 +102,6 @@ def get(self, request: Request) -> Response: except TimeoutError: logger.warning("Timeout when fetching models from Seer") raise SeerTimeoutError() - except (Exception, json.JSONDecodeError): + except Exception: logger.exception("Error fetching models from Seer") raise SeerConnectionError() diff --git a/tests/sentry/api/endpoints/test_seer_models.py b/tests/sentry/api/endpoints/test_seer_models.py index d252154d539955..418a819ab7415e 100644 --- a/tests/sentry/api/endpoints/test_seer_models.py +++ b/tests/sentry/api/endpoints/test_seer_models.py @@ -1,10 +1,8 @@ from unittest.mock import MagicMock, patch -import requests -from django.conf import settings from rest_framework import status -from sentry.seer.signed_seer_api import sign_with_seer_secret +from sentry.seer.models import SeerApiError from sentry.testutils.cases import APITestCase @@ -15,84 +13,65 @@ def setUp(self) -> None: super().setUp() self.url = "/api/0/seer/models/" - @patch("sentry.api.endpoints.seer_models.requests.get") - def test_get_models_successful(self, mock_get: MagicMock) -> None: + @patch("sentry.api.endpoints.seer_models.make_signed_seer_api_request") + def test_get_models_successful(self, mock_request: MagicMock) -> None: """Test successful retrieval of models from Seer.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { + mock_request.return_value.status = 200 + mock_request.return_value.json.return_value = { "models": ["gpt-4", "claude-3", "gemini-pro"], } - mock_get.return_value = mock_response response = self.client.get(self.url) assert response.status_code == status.HTTP_200_OK assert response.data == {"models": ["gpt-4", "claude-3", "gemini-pro"]} + mock_request.assert_called_once() - expected_url = f"{settings.SEER_AUTOFIX_URL}/v1/models" - expected_headers = { - "content-type": "application/json;charset=utf-8", - **sign_with_seer_secret(b""), - } - mock_get.assert_called_once_with( - expected_url, - headers=expected_headers, - timeout=5, - ) - - @patch("sentry.api.endpoints.seer_models.requests.get") - def test_get_models_no_authentication_required(self, mock_get: MagicMock) -> None: + @patch("sentry.api.endpoints.seer_models.make_signed_seer_api_request") + def test_get_models_no_authentication_required(self, mock_request: MagicMock) -> None: """Test that the endpoint works without authentication.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"models": ["gpt-4"]} - mock_get.return_value = mock_response + mock_request.return_value.status = 200 + mock_request.return_value.json.return_value = {"models": ["gpt-4"]} response = self.client.get(self.url) assert response.status_code == status.HTTP_200_OK - @patch("sentry.api.endpoints.seer_models.requests.get") - def test_get_models_timeout(self, mock_get: MagicMock) -> None: + @patch("sentry.api.endpoints.seer_models.make_signed_seer_api_request") + def test_get_models_timeout(self, mock_request: MagicMock) -> None: """Test handling of timeout errors.""" - mock_get.side_effect = requests.exceptions.Timeout("Request timed out") + mock_request.side_effect = TimeoutError("Request timed out") response = self.client.get(self.url) assert response.status_code == status.HTTP_504_GATEWAY_TIMEOUT assert response.data == {"detail": "Request to Seer timed out"} - @patch("sentry.api.endpoints.seer_models.requests.get") - def test_get_models_request_exception(self, mock_get: MagicMock) -> None: + @patch("sentry.api.endpoints.seer_models.make_signed_seer_api_request") + def test_get_models_request_exception(self, mock_request: MagicMock) -> None: """Test handling of request exceptions.""" - mock_get.side_effect = requests.exceptions.RequestException("Connection error") + mock_request.side_effect = SeerApiError("Connection error", 500) response = self.client.get(self.url) assert response.status_code == status.HTTP_502_BAD_GATEWAY assert response.data == {"detail": "Failed to fetch models from Seer"} - @patch("sentry.api.endpoints.seer_models.requests.get") - def test_get_models_http_error(self, mock_get: MagicMock) -> None: + @patch("sentry.api.endpoints.seer_models.make_signed_seer_api_request") + def test_get_models_http_error(self, mock_request: MagicMock) -> None: """Test handling of HTTP errors from Seer.""" - mock_response = MagicMock() - mock_response.status_code = 500 - mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError("Server error") - mock_get.return_value = mock_response + mock_request.return_value.status = 500 response = self.client.get(self.url) assert response.status_code == status.HTTP_502_BAD_GATEWAY assert response.data == {"detail": "Failed to fetch models from Seer"} - @patch("sentry.api.endpoints.seer_models.requests.get") - def test_get_models_empty_response(self, mock_get: MagicMock) -> None: + @patch("sentry.api.endpoints.seer_models.make_signed_seer_api_request") + def test_get_models_empty_response(self, mock_request: MagicMock) -> None: """Test handling of empty models list.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = {"models": []} - mock_get.return_value = mock_response + mock_request.return_value.status = 200 + mock_request.return_value.json.return_value = {"models": []} response = self.client.get(self.url) diff --git a/tests/sentry/tasks/test_seer.py b/tests/sentry/tasks/test_seer.py index 6521fdebe4c579..fb95fec2d35b95 100644 --- a/tests/sentry/tasks/test_seer.py +++ b/tests/sentry/tasks/test_seer.py @@ -1,11 +1,11 @@ from __future__ import annotations +from unittest.mock import MagicMock, patch + import orjson import pytest -import responses -from django.conf import settings -from sentry.seer.signed_seer_api import sign_with_seer_secret +from sentry.seer.models import SeerApiError from sentry.tasks.seer import cleanup_seer_repository_preferences from sentry.testutils.cases import TestCase @@ -17,91 +17,56 @@ def setUp(self) -> None: self.repo_external_id = "12345" self.repo_provider = "github" - @responses.activate - def test_cleanup_seer_repository_preferences_success(self) -> None: + @patch("sentry.tasks.seer.make_signed_seer_api_request") + def test_cleanup_seer_repository_preferences_success(self, mock_request: MagicMock) -> None: """Test successful cleanup of Seer repository preferences.""" - # Mock the Seer API response - responses.add( - responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/project-preference/remove-repository", - status=200, - ) + mock_request.return_value.status = 200 - # Call the task cleanup_seer_repository_preferences( organization_id=self.organization.id, repo_external_id=self.repo_external_id, repo_provider=self.repo_provider, ) - # Verify the request was made with correct data - assert len(responses.calls) == 1 - request = responses.calls[0].request - - expected_body = orjson.dumps( - { - "organization_id": self.organization.id, - "repo_provider": self.repo_provider, - "repo_external_id": self.repo_external_id, - } - ) - - assert request.body == expected_body - assert request.headers["content-type"] == "application/json;charset=utf-8" - - # Verify the request was signed - expected_headers = sign_with_seer_secret(expected_body) - for header_name, header_value in expected_headers.items(): - assert request.headers[header_name] == header_value + mock_request.assert_called_once() + body = orjson.loads(mock_request.call_args[0][2]) + assert body == { + "organization_id": self.organization.id, + "repo_provider": self.repo_provider, + "repo_external_id": self.repo_external_id, + } - @responses.activate - def test_cleanup_seer_repository_preferences_api_error(self) -> None: + @patch("sentry.tasks.seer.make_signed_seer_api_request") + def test_cleanup_seer_repository_preferences_api_error(self, mock_request: MagicMock) -> None: """Test handling of Seer API errors.""" - # Mock the Seer API to return an error - responses.add( - responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/project-preference/remove-repository", - status=500, - ) + mock_request.return_value.status = 500 - # Call the task and expect it to raise an exception - with pytest.raises(Exception): + with pytest.raises(SeerApiError): cleanup_seer_repository_preferences( organization_id=self.organization.id, repo_external_id=self.repo_external_id, repo_provider=self.repo_provider, ) - @responses.activate - def test_cleanup_seer_repository_preferences_organization_not_found(self) -> None: + @patch("sentry.tasks.seer.make_signed_seer_api_request") + def test_cleanup_seer_repository_preferences_organization_not_found( + self, mock_request: MagicMock + ) -> None: """Test handling when organization doesn't exist.""" - # Mock the Seer API response for non-existent organization - responses.add( - responses.POST, - f"{settings.SEER_AUTOFIX_URL}/v1/project-preference/remove-repository", - status=200, - ) + mock_request.return_value.status = 200 - # Use a non-existent organization ID nonexistent_organization_id = 99999 - # Call the task - it should still make the API call even if org doesn't exist locally cleanup_seer_repository_preferences( organization_id=nonexistent_organization_id, repo_external_id=self.repo_external_id, repo_provider=self.repo_provider, ) - # The API call should be made regardless of local organization existence - assert len(responses.calls) == 1 - request = responses.calls[0].request - - expected_body = orjson.dumps( - { - "organization_id": nonexistent_organization_id, - "repo_provider": self.repo_provider, - "repo_external_id": self.repo_external_id, - } - ) - - assert request.body == expected_body + mock_request.assert_called_once() + body = orjson.loads(mock_request.call_args[0][2]) + assert body == { + "organization_id": nonexistent_organization_id, + "repo_provider": self.repo_provider, + "repo_external_id": self.repo_external_id, + }