-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
chore(eco): Silences errors from 400 responses when creating Jira issues #100078
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
6a38811
b1d8146
0831019
54e6c37
6098b3e
02e347e
a43d777
dbcc2c7
7f58d2d
fb5ca18
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,9 +2,10 @@ | |
|
|
||
| import logging | ||
| import re | ||
| import sys | ||
| from collections.abc import Mapping, Sequence | ||
| from operator import attrgetter | ||
| from typing import Any, TypedDict | ||
| from typing import Any, NoReturn, TypedDict | ||
|
|
||
| import sentry_sdk | ||
| from django.conf import settings | ||
|
|
@@ -51,6 +52,7 @@ | |
| IntegrationFormError, | ||
| ) | ||
| from sentry.silo.base import all_silo_function | ||
| from sentry.users.models.identity import Identity | ||
| from sentry.users.models.user import User | ||
| from sentry.users.services.user import RpcUser | ||
| from sentry.users.services.user.service import user_service | ||
|
|
@@ -970,6 +972,31 @@ def create_issue(self, data, **kwargs): | |
| # Immediately fetch and return the created issue. | ||
| return self.get_issue(issue_key) | ||
|
|
||
| def raise_error(self, exc: Exception, identity: Identity | None = None) -> NoReturn: | ||
| """ | ||
| Overrides the base `raise_error` method to treat ApiInvalidRequestErrors | ||
| as configuration errors when we don't have error field handling for the | ||
| response. | ||
|
|
||
| This is because the majority of Jira errors we receive are external | ||
| configuration problems, like required fields missing. | ||
| """ | ||
| if isinstance(exc, ApiInvalidRequestError): | ||
| if exc.json: | ||
| error_fields = self.error_fields_from_json(exc.json) | ||
| if error_fields is not None: | ||
| raise IntegrationFormError(error_fields).with_traceback(sys.exc_info()[2]) | ||
|
|
||
| logger.warning( | ||
| "sentry.jira.raise_error.api_invalid_request_error", | ||
| extra={ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could have There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh this is a really good point. This could help us cut down on the XML/HTML junk we get back from Jira. I'll do that instead, ty! |
||
| "exception_type": type(exc).__name__, | ||
| "request_body": str(exc.text), | ||
| }, | ||
| ) | ||
| raise IntegrationConfigurationError(exc.text) from exc | ||
| super().raise_error(exc, identity=identity) | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| def sync_assignee_outbound( | ||
| self, | ||
| external_issue: ExternalIssue, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
|
|
||
| from sentry import features | ||
| from sentry.constants import ObjectStatus | ||
| from sentry.exceptions import InvalidIdentity | ||
| from sentry.incidents.grouptype import MetricIssueEvidenceData | ||
| from sentry.incidents.models.incident import TriggerStatus | ||
| from sentry.incidents.typings.metric_detector import ( | ||
|
|
@@ -27,9 +28,15 @@ | |
| from sentry.notifications.types import TEST_NOTIFICATION_ID | ||
| from sentry.rules.processing.processor import activate_downstream_actions | ||
| from sentry.services.eventstore.models import GroupEvent | ||
| from sentry.shared_integrations.exceptions import ( | ||
| ApiError, | ||
| IntegrationConfigurationError, | ||
| IntegrationFormError, | ||
| ) | ||
| from sentry.taskworker.retry import RetryError | ||
| from sentry.taskworker.workerchild import ProcessingDeadlineExceeded | ||
| from sentry.types.activity import ActivityType | ||
| from sentry.types.rules import RuleFuture | ||
| from sentry.utils.safe import safe_execute | ||
| from sentry.workflow_engine.models import Action, AlertRuleWorkflow, Detector | ||
| from sentry.workflow_engine.types import DetectorPriorityLevel, WorkflowEventData | ||
| from sentry.workflow_engine.typings.notification_action import ( | ||
|
|
@@ -41,6 +48,8 @@ | |
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| FutureCallback = Callable[[GroupEvent, Sequence[RuleFuture]], Any] | ||
|
|
||
|
|
||
| class RuleData(TypedDict): | ||
| actions: list[dict[str, Any]] | ||
|
|
@@ -63,6 +72,48 @@ def handle_workflow_action( | |
| raise NotImplementedError | ||
|
|
||
|
|
||
| EXCEPTION_IGNORE_LIST = (IntegrationFormError, IntegrationConfigurationError, InvalidIdentity) | ||
| RETRYABLE_EXCEPTIONS = (ApiError,) | ||
|
|
||
|
|
||
| def invoke_future_with_error_handling( | ||
| event_data: WorkflowEventData, | ||
| callback: FutureCallback, | ||
| future: Sequence[RuleFuture], | ||
| ) -> None: | ||
| # WorkflowEventData should only ever be a GroupEvent in this context, so we | ||
| # narrow the type here to keep mypy happy. | ||
| assert isinstance( | ||
| event_data.event, GroupEvent | ||
| ), f"Expected a GroupEvent, received: {type(event_data.event).__name__}" | ||
| try: | ||
| callback(event_data.event, future) | ||
| except EXCEPTION_IGNORE_LIST: | ||
| # no-op on any exceptions in the ignore list. We likely have | ||
| # reporting for them in the integration code already. | ||
| pass | ||
| except ProcessingDeadlineExceeded: | ||
| # We need to reraise ProcessingDeadlineExceeded for workflow engine to | ||
| # monitor and potentially retry this action. | ||
| raise | ||
| except RETRYABLE_EXCEPTIONS as e: | ||
| raise RetryError from e | ||
| except Exception as e: | ||
| # This is just a redefinition of the safe_execute util function, as we | ||
| # still want to report any unhandled exceptions. | ||
| if hasattr(callback, "im_class"): | ||
| cls = callback.im_class | ||
| else: | ||
| cls = callback.__class__ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Python 3 Compatibility Issue with Method Class RetrievalThe |
||
|
|
||
| func_name = getattr(callback, "__name__", str(callback)) | ||
| cls_name = cls.__name__ | ||
| local_logger = logging.getLogger(f"sentry.safe_action.{cls_name.lower()}") | ||
|
|
||
| local_logger.exception("%s.process_error", func_name, extra={"exception": e}) | ||
| return None | ||
|
|
||
|
|
||
| class BaseIssueAlertHandler(ABC): | ||
| """ | ||
| Base class for invoking the legacy issue alert registry. | ||
|
|
@@ -220,9 +271,7 @@ def get_rule_futures( | |
| @staticmethod | ||
| def execute_futures( | ||
| event_data: WorkflowEventData, | ||
| futures: Collection[ | ||
| tuple[Callable[[GroupEvent, Sequence[RuleFuture]], None], list[RuleFuture]] | ||
| ], | ||
| futures: Collection[tuple[FutureCallback, list[RuleFuture]]], | ||
| ) -> None: | ||
| """ | ||
| This method will execute the futures. | ||
|
|
@@ -234,7 +283,7 @@ def execute_futures( | |
| ) | ||
|
|
||
| for callback, future in futures: | ||
| safe_execute(callback, event_data.event, future) | ||
| invoke_future_with_error_handling(event_data, callback, future) | ||
|
|
||
| @staticmethod | ||
| def send_test_notification( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,11 @@ | |
| from sentry.integrations.services.integration import integration_service | ||
| from sentry.models.grouplink import GroupLink | ||
| from sentry.models.groupmeta import GroupMeta | ||
| from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError | ||
| from sentry.shared_integrations.exceptions import ( | ||
| IntegrationConfigurationError, | ||
| IntegrationError, | ||
| IntegrationFormError, | ||
| ) | ||
| from sentry.silo.base import SiloMode | ||
| from sentry.testutils.cases import APITestCase, IntegrationTestCase | ||
| from sentry.testutils.factories import EventType | ||
|
|
@@ -711,6 +715,59 @@ def test_create_issue(self) -> None: | |
| "key": "APP-123", | ||
| } | ||
|
|
||
| @responses.activate | ||
| def test_create_issue_with_form_error(self) -> None: | ||
| responses.add( | ||
| responses.GET, | ||
| "https://example.atlassian.net/rest/api/2/issue/createmeta", | ||
| body=StubService.get_stub_json("jira", "createmeta_response.json"), | ||
| content_type="json", | ||
| ) | ||
| responses.add( | ||
| responses.POST, | ||
| "https://example.atlassian.net/rest/api/2/issue", | ||
| status=400, | ||
| body=json.dumps({"errors": {"issuetype": ["Issue type is required."]}}), | ||
| content_type="json", | ||
| ) | ||
|
|
||
| installation = self.integration.get_installation(self.organization.id) | ||
| with pytest.raises(IntegrationFormError): | ||
| installation.create_issue( | ||
| { | ||
| "title": "example summary", | ||
| "description": "example bug report", | ||
| "issuetype": "1", | ||
| "project": "10000", | ||
| } | ||
| ) | ||
|
|
||
| @responses.activate | ||
| def test_create_issue_with_configuration_error(self) -> None: | ||
| responses.add( | ||
| responses.GET, | ||
| "https://example.atlassian.net/rest/api/2/issue/createmeta", | ||
| body=StubService.get_stub_json("jira", "createmeta_response.json"), | ||
| content_type="json", | ||
| ) | ||
| responses.add( | ||
| responses.POST, | ||
| "https://example.atlassian.net/rest/api/2/issue", | ||
| status=400, | ||
| body=json.dumps({"error": "Jira had an oopsie"}), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oopsie |
||
| content_type="json", | ||
| ) | ||
| installation = self.integration.get_installation(self.organization.id) | ||
| with pytest.raises(IntegrationConfigurationError): | ||
| installation.create_issue( | ||
| { | ||
| "title": "example summary", | ||
| "description": "example bug report", | ||
| "issuetype": "1", | ||
| "project": "10000", | ||
| } | ||
| ) | ||
|
|
||
| @responses.activate | ||
| def test_create_issue_labels_and_option(self) -> None: | ||
| installation = self.integration.get_installation(self.organization.id) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it reasonable to do more checking of the message like checking if "is required" is in the response from Jira (like adding to
CUSTOM_ERROR_MESSAGE_MATCHERS)? I slightly worry about overreporting halts when they should be failuresThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this seems overly permissive for now. 🤔 I can start by logging out the invalid response bodies, then creating a list to check against. The problem is, I've seen so many distinct error messages, including some that have HTML bodies, that it'll be impossible to string match against all of them.