Skip to content

Commit 134887b

Browse files
chore(eco): Silences errors from 400 responses when creating Jira issues (#100078)
1 parent 0828bf5 commit 134887b

File tree

6 files changed

+291
-25
lines changed

6 files changed

+291
-25
lines changed

src/sentry/integrations/jira/integration.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import logging
44
import re
5+
import sys
56
from collections.abc import Mapping, Sequence
67
from operator import attrgetter
7-
from typing import Any, TypedDict
8+
from typing import Any, NoReturn, TypedDict
89

910
import sentry_sdk
1011
from django.conf import settings
@@ -51,6 +52,7 @@
5152
IntegrationFormError,
5253
)
5354
from sentry.silo.base import all_silo_function
55+
from sentry.users.models.identity import Identity
5456
from sentry.users.models.user import User
5557
from sentry.users.services.user import RpcUser
5658
from sentry.users.services.user.service import user_service
@@ -970,6 +972,31 @@ def create_issue(self, data, **kwargs):
970972
# Immediately fetch and return the created issue.
971973
return self.get_issue(issue_key)
972974

975+
def raise_error(self, exc: Exception, identity: Identity | None = None) -> NoReturn:
976+
"""
977+
Overrides the base `raise_error` method to treat ApiInvalidRequestErrors
978+
as configuration errors when we don't have error field handling for the
979+
response.
980+
981+
This is because the majority of Jira errors we receive are external
982+
configuration problems, like required fields missing.
983+
"""
984+
if isinstance(exc, ApiInvalidRequestError):
985+
if exc.json:
986+
error_fields = self.error_fields_from_json(exc.json)
987+
if error_fields is not None:
988+
raise IntegrationFormError(error_fields).with_traceback(sys.exc_info()[2])
989+
990+
logger.warning(
991+
"sentry.jira.raise_error.api_invalid_request_error",
992+
extra={
993+
"exception_type": type(exc).__name__,
994+
"request_body": str(exc.json),
995+
},
996+
)
997+
raise IntegrationConfigurationError(exc.text) from exc
998+
super().raise_error(exc, identity=identity)
999+
9731000
def sync_assignee_outbound(
9741001
self,
9751002
external_issue: ExternalIssue,

src/sentry/notifications/notification_action/types.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from sentry import features
1111
from sentry.constants import ObjectStatus
12+
from sentry.exceptions import InvalidIdentity
1213
from sentry.incidents.grouptype import MetricIssueEvidenceData
1314
from sentry.incidents.models.incident import TriggerStatus
1415
from sentry.incidents.typings.metric_detector import (
@@ -27,9 +28,15 @@
2728
from sentry.notifications.types import TEST_NOTIFICATION_ID
2829
from sentry.rules.processing.processor import activate_downstream_actions
2930
from sentry.services.eventstore.models import GroupEvent
31+
from sentry.shared_integrations.exceptions import (
32+
ApiError,
33+
IntegrationConfigurationError,
34+
IntegrationFormError,
35+
)
36+
from sentry.taskworker.retry import RetryError
37+
from sentry.taskworker.workerchild import ProcessingDeadlineExceeded
3038
from sentry.types.activity import ActivityType
3139
from sentry.types.rules import RuleFuture
32-
from sentry.utils.safe import safe_execute
3340
from sentry.workflow_engine.models import Action, AlertRuleWorkflow, Detector
3441
from sentry.workflow_engine.types import DetectorPriorityLevel, WorkflowEventData
3542
from sentry.workflow_engine.typings.notification_action import (
@@ -41,6 +48,8 @@
4148

4249
logger = logging.getLogger(__name__)
4350

51+
FutureCallback = Callable[[GroupEvent, Sequence[RuleFuture]], Any]
52+
4453

4554
class RuleData(TypedDict):
4655
actions: list[dict[str, Any]]
@@ -63,6 +72,48 @@ def handle_workflow_action(
6372
raise NotImplementedError
6473

6574

75+
EXCEPTION_IGNORE_LIST = (IntegrationFormError, IntegrationConfigurationError, InvalidIdentity)
76+
RETRYABLE_EXCEPTIONS = (ApiError,)
77+
78+
79+
def invoke_future_with_error_handling(
80+
event_data: WorkflowEventData,
81+
callback: FutureCallback,
82+
future: Sequence[RuleFuture],
83+
) -> None:
84+
# WorkflowEventData should only ever be a GroupEvent in this context, so we
85+
# narrow the type here to keep mypy happy.
86+
assert isinstance(
87+
event_data.event, GroupEvent
88+
), f"Expected a GroupEvent, received: {type(event_data.event).__name__}"
89+
try:
90+
callback(event_data.event, future)
91+
except EXCEPTION_IGNORE_LIST:
92+
# no-op on any exceptions in the ignore list. We likely have
93+
# reporting for them in the integration code already.
94+
pass
95+
except ProcessingDeadlineExceeded:
96+
# We need to reraise ProcessingDeadlineExceeded for workflow engine to
97+
# monitor and potentially retry this action.
98+
raise
99+
except RETRYABLE_EXCEPTIONS as e:
100+
raise RetryError from e
101+
except Exception as e:
102+
# This is just a redefinition of the safe_execute util function, as we
103+
# still want to report any unhandled exceptions.
104+
if hasattr(callback, "im_class"):
105+
cls = callback.im_class
106+
else:
107+
cls = callback.__class__
108+
109+
func_name = getattr(callback, "__name__", str(callback))
110+
cls_name = cls.__name__
111+
local_logger = logging.getLogger(f"sentry.safe_action.{cls_name.lower()}")
112+
113+
local_logger.exception("%s.process_error", func_name, extra={"exception": e})
114+
return None
115+
116+
66117
class BaseIssueAlertHandler(ABC):
67118
"""
68119
Base class for invoking the legacy issue alert registry.
@@ -225,9 +276,7 @@ def get_rule_futures(
225276
@staticmethod
226277
def execute_futures(
227278
event_data: WorkflowEventData,
228-
futures: Collection[
229-
tuple[Callable[[GroupEvent, Sequence[RuleFuture]], None], list[RuleFuture]]
230-
],
279+
futures: Collection[tuple[FutureCallback, list[RuleFuture]]],
231280
) -> None:
232281
"""
233282
This method will execute the futures.
@@ -239,7 +288,7 @@ def execute_futures(
239288
)
240289

241290
for callback, future in futures:
242-
safe_execute(callback, event_data.event, future)
291+
invoke_future_with_error_handling(event_data, callback, future)
243292

244293
@staticmethod
245294
def send_test_notification(

src/sentry/rules/actions/integrations/create_ticket/utils.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
from sentry.integrations.services.integration.model import RpcIntegration
1919
from sentry.integrations.services.integration.service import integration_service
2020
from sentry.models.grouplink import GroupLink
21-
from sentry.notifications.types import TEST_NOTIFICATION_ID
2221
from sentry.notifications.utils.links import create_link_to_workflow
2322
from sentry.services.eventstore.models import GroupEvent
2423
from sentry.shared_integrations.exceptions import (
@@ -187,10 +186,8 @@ def create_issue(event: GroupEvent, futures: Sequence[RuleFuture]) -> None:
187186
) as e:
188187
# Most of the time, these aren't explicit failures, they're
189188
# some misconfiguration of an issue field - typically Jira.
190-
# We only want to raise if the rule_id is -1 because that means we're testing the action
191189
lifecycle.record_halt(e)
192-
if rule_id == TEST_NOTIFICATION_ID:
193-
raise
190+
raise
194191
# If we successfully created the issue, we want to create the link
195192
else:
196193
create_link(integration, installation, event, response)

tests/sentry/integrations/jira/test_integration.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
from sentry.integrations.services.integration import integration_service
2020
from sentry.models.grouplink import GroupLink
2121
from sentry.models.groupmeta import GroupMeta
22-
from sentry.shared_integrations.exceptions import IntegrationConfigurationError, IntegrationError
22+
from sentry.shared_integrations.exceptions import (
23+
IntegrationConfigurationError,
24+
IntegrationError,
25+
IntegrationFormError,
26+
)
2327
from sentry.silo.base import SiloMode
2428
from sentry.testutils.cases import APITestCase, IntegrationTestCase
2529
from sentry.testutils.factories import EventType
@@ -711,6 +715,59 @@ def test_create_issue(self) -> None:
711715
"key": "APP-123",
712716
}
713717

718+
@responses.activate
719+
def test_create_issue_with_form_error(self) -> None:
720+
responses.add(
721+
responses.GET,
722+
"https://example.atlassian.net/rest/api/2/issue/createmeta",
723+
body=StubService.get_stub_json("jira", "createmeta_response.json"),
724+
content_type="json",
725+
)
726+
responses.add(
727+
responses.POST,
728+
"https://example.atlassian.net/rest/api/2/issue",
729+
status=400,
730+
body=json.dumps({"errors": {"issuetype": ["Issue type is required."]}}),
731+
content_type="json",
732+
)
733+
734+
installation = self.integration.get_installation(self.organization.id)
735+
with pytest.raises(IntegrationFormError):
736+
installation.create_issue(
737+
{
738+
"title": "example summary",
739+
"description": "example bug report",
740+
"issuetype": "1",
741+
"project": "10000",
742+
}
743+
)
744+
745+
@responses.activate
746+
def test_create_issue_with_configuration_error(self) -> None:
747+
responses.add(
748+
responses.GET,
749+
"https://example.atlassian.net/rest/api/2/issue/createmeta",
750+
body=StubService.get_stub_json("jira", "createmeta_response.json"),
751+
content_type="json",
752+
)
753+
responses.add(
754+
responses.POST,
755+
"https://example.atlassian.net/rest/api/2/issue",
756+
status=400,
757+
body=json.dumps({"error": "Jira had an oopsie"}),
758+
content_type="json",
759+
)
760+
installation = self.integration.get_installation(self.organization.id)
761+
with pytest.raises(IntegrationConfigurationError):
762+
installation.create_issue(
763+
{
764+
"title": "example summary",
765+
"description": "example bug report",
766+
"issuetype": "1",
767+
"project": "10000",
768+
}
769+
)
770+
714771
@responses.activate
715772
def test_create_issue_labels_and_option(self) -> None:
716773
installation = self.integration.get_installation(self.organization.id)

tests/sentry/integrations/jira/test_ticket_action.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@
1313
from sentry.shared_integrations.exceptions import (
1414
ApiInvalidRequestError,
1515
IntegrationConfigurationError,
16-
IntegrationError,
1716
IntegrationFormError,
1817
)
19-
from sentry.testutils.asserts import assert_failure_metric, assert_halt_metric
18+
from sentry.testutils.asserts import assert_halt_metric
2019
from sentry.testutils.cases import RuleTestCase
2120
from sentry.testutils.skips import requires_snuba
2221
from sentry.types.rules import RuleFuture
@@ -143,18 +142,16 @@ def raise_api_error(*args, **kwargs):
143142
rule_object = Rule.objects.get(id=response.data["id"])
144143
event = self.get_event()
145144

146-
with pytest.raises(IntegrationError):
147-
# Trigger its `after`, but with a broken client which should raise
148-
# an ApiInvalidRequestError, which is reraised as an IntegrationError.
145+
with pytest.raises(IntegrationConfigurationError):
149146
self.trigger(event, rule_object)
150147

151148
assert mock_record_event.call_count == 2
152-
start, failure = mock_record_event.call_args_list
149+
start, halt = mock_record_event.call_args_list
153150
assert start.args == (EventLifecycleOutcome.STARTED,)
154151

155-
assert_failure_metric(
152+
assert_halt_metric(
156153
mock_record_event,
157-
IntegrationError("Error Communicating with Jira (HTTP 400): unknown error"),
154+
IntegrationConfigurationError(),
158155
)
159156

160157
def test_fails_validation(self) -> None:
@@ -211,7 +208,8 @@ def raise_api_error_with_payload(*args, **kwargs):
211208
rule_object = Rule.objects.get(id=response.data["id"])
212209
event = self.get_event()
213210

214-
self.trigger(event, rule_object)
211+
with pytest.raises(IntegrationFormError):
212+
self.trigger(event, rule_object)
215213

216214
assert mock_record_event.call_count == 2
217215
start, halt = mock_record_event.call_args_list
@@ -233,7 +231,8 @@ def test_halts_with_external_api_configuration_error(
233231
rule_object = Rule.objects.get(id=response.data["id"])
234232
event = self.get_event()
235233

236-
self.trigger(event, rule_object)
234+
with pytest.raises(IntegrationConfigurationError):
235+
self.trigger(event, rule_object)
237236

238237
assert mock_record_event.call_count == 2
239238
start, halt = mock_record_event.call_args_list

0 commit comments

Comments
 (0)