Skip to content

Commit 52db83c

Browse files
committed
fix(investigations): Raise apply errors and type ActionInvalid's messages
1 parent 05c7200 commit 52db83c

6 files changed

Lines changed: 112 additions & 103 deletions

File tree

‎src/sentry/investigations/endpoints/organization_investigation_action_request_apply.py‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
)
2323
from sentry.investigations.endpoints.validators import ActionRequestApplyValidator
2424
from sentry.investigations.models import Investigation
25-
from sentry.investigations.services.action_kinds.base import ApplyErrorCode, CannotApplyReason
25+
from sentry.investigations.services.action_kinds.base import (
26+
ApplyErrorCode,
27+
ApplyFailed,
28+
CannotApplyReason,
29+
)
2630
from sentry.investigations.services.action_requests import apply_action_request
2731
from sentry.models.organization import Organization
2832

@@ -79,18 +83,19 @@ def post(
7983
validator = ActionRequestApplyValidator(data=request.data)
8084
if not validator.is_valid():
8185
return Response(validator.errors, status=status.HTTP_400_BAD_REQUEST)
82-
result = apply_action_request(
83-
investigation=investigation,
84-
action_request_id=action_request_id,
85-
request=request,
86-
actor_id=actor_id,
87-
dry_run=validator.validated_data["dry_run"],
88-
)
89-
if result.error is not None:
90-
return Response(result.error, status=_STATUS.get(result.error["code"], 409))
86+
try:
87+
action_request = apply_action_request(
88+
investigation=investigation,
89+
action_request_id=action_request_id,
90+
request=request,
91+
actor_id=actor_id,
92+
dry_run=validator.validated_data["dry_run"],
93+
)
94+
except ApplyFailed as failed:
95+
return Response(failed.error, status=_STATUS.get(failed.error["code"], 409))
9196
body: InvestigationActionRequestApplyResponse = {
9297
"actionRequest": serialize(
93-
result.action_request,
98+
action_request,
9499
request.user,
95100
InvestigationActionRequestSerializer(access=request.access),
96101
),

‎src/sentry/investigations/services/action_kinds/base.py‎

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

33
import abc
4-
from collections.abc import Sequence
4+
from collections.abc import Mapping, Sequence
55
from dataclasses import dataclass
66
from enum import StrEnum
7-
from typing import Any, NotRequired, TypedDict
7+
from typing import NotRequired, TypedDict
88

99
from django.contrib.auth.models import AnonymousUser
1010
from rest_framework.request import Request
@@ -65,10 +65,10 @@ class ActionRequestError(TypedDict):
6565
messages: NotRequired[list[str]]
6666

6767

68-
@dataclass(frozen=True)
69-
class ApplyResult:
70-
action_request: InvestigationActionRequest
71-
error: ApplyError | None = None
68+
class ApplyFailed(Exception):
69+
def __init__(self, error: ApplyError) -> None:
70+
super().__init__(error["detail"])
71+
self.error = error
7272

7373

7474
@dataclass(frozen=True)
@@ -91,11 +91,11 @@ def __init__(self, current: dict[str, SettingValue]) -> None:
9191

9292

9393
class ActionInvalid(Exception):
94-
"""The change wouldn't save. `errors` are the validator's errors."""
94+
"""The change wouldn't save, and why."""
9595

96-
def __init__(self, errors: dict[str, Any] | list[Any]) -> None:
96+
def __init__(self, messages: list[str]) -> None:
9797
super().__init__("The action is invalid.")
98-
self.errors = errors
98+
self.messages = messages
9999

100100

101101
class ActionKind(abc.ABC):
@@ -121,7 +121,7 @@ def check(self, context: ApplyContext) -> None:
121121
cannot_apply_reasons can leave this as is.
122122
"""
123123

124-
def apply(self, context: ApplyContext) -> dict[str, object]:
124+
def apply(self, context: ApplyContext) -> Mapping[str, object]:
125125
"""Make the change, and return what the activity feed should show about it. Raise
126126
ActionStale or ActionInvalid instead of updating the request; the caller records it.
127127
"""

‎src/sentry/investigations/services/action_kinds/update_detector.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
InvestigationActionRequest,
2828
InvestigationOrchestrationRun,
2929
)
30-
from sentry.investigations.services.action_kinds.base import ActionKind, CannotApplyReason
30+
from sentry.investigations.services.action_kinds.base import (
31+
ActionKind,
32+
CannotApplyReason,
33+
SettingValue,
34+
)
3135
from sentry.models.organization import Organization
3236
from sentry.snuba.models import SnubaQuery
3337
from sentry.users.models.user import User
@@ -124,8 +128,6 @@ def trim(cls, action: dict[str, Any]) -> dict[str, Any] | None:
124128
}
125129

126130

127-
DetectorValue = int | float | str | None
128-
129131
_THRESHOLD_RESULTS: dict[
130132
Literal["highThreshold", "mediumThreshold", "resolutionThreshold"], DetectorPriorityLevel
131133
] = {
@@ -211,12 +213,12 @@ def load(cls, detector: Detector, snuba_query: SnubaQuery) -> DetectorState:
211213
)
212214
return cls(data_source=data_source, condition_group=condition_group)
213215

214-
def values(self) -> dict[str, DetectorValue]:
216+
def values(self) -> dict[str, SettingValue]:
215217
"""The monitor's current settings, keyed like Seer's changes. A threshold is left out
216218
unless exactly one condition sets it.
217219
"""
218220

219-
values: dict[str, DetectorValue] = {
221+
values: dict[str, SettingValue] = {
220222
"timeWindowSeconds": self.data_source["timeWindow"],
221223
"query": self.data_source["query"],
222224
"aggregate": self.data_source["aggregate"],

‎src/sentry/investigations/services/action_requests.py‎

Lines changed: 64 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@
2020
from sentry.investigations.services.action_kinds import get_action_kind, shared_cannot_apply_reason
2121
from sentry.investigations.services.action_kinds.base import (
2222
ActionInvalid,
23+
ActionKind,
2324
ActionRequestError,
2425
ActionStale,
2526
ApplyContext,
2627
ApplyError,
2728
ApplyErrorCode,
28-
ApplyResult,
29+
ApplyFailed,
2930
CannotApplyReason,
3031
)
3132

@@ -229,11 +230,11 @@ def supersede_block_action_requests(investigation: Investigation, block_keys: Se
229230
)
230231

231232

232-
# The usual detail for a reason; anything a kind refuses gets the last one.
233233
_REASON_DETAILS = {
234234
CannotApplyReason.INVESTIGATION_ARCHIVED: "Archived investigations are read-only.",
235235
CannotApplyReason.UNSUPPORTED_ACTION: "This request has no action to apply.",
236236
}
237+
_DEFAULT_REASON_DETAIL = "This action can't be applied."
237238

238239

239240
def _not_suggested_error(action_request: InvestigationActionRequest) -> ApplyError:
@@ -254,22 +255,39 @@ def _not_suggested_error(action_request: InvestigationActionRequest) -> ApplyErr
254255
}
255256

256257

257-
def _error_messages(errors: object) -> list[str]:
258-
if isinstance(errors, dict):
259-
return [message for value in errors.values() for message in _error_messages(value)]
260-
if isinstance(errors, list):
261-
return [message for value in errors for message in _error_messages(value)]
262-
return [str(errors)]
258+
def _check_can_apply(
259+
investigation: Investigation, action_request: InvestigationActionRequest, request: Request
260+
) -> ActionKind:
261+
"""Return the request's kind, or raise ApplyFailed if the viewer can't apply it."""
262+
263+
reason = shared_cannot_apply_reason(investigation, action_request)
264+
if reason == CannotApplyReason.NOT_SUGGESTED:
265+
raise ApplyFailed(_not_suggested_error(action_request))
266+
if reason is None:
267+
kind = get_action_kind(action_request.action_kind)
268+
assert kind is not None
269+
# The same check that decides canApply guards the endpoint, so a kind can't forget it.
270+
reason = kind.cannot_apply_reasons(
271+
[action_request],
272+
investigations={investigation.id: investigation},
273+
access=request.access,
274+
user=request.user,
275+
)[action_request.id]
276+
if reason is None:
277+
return kind
278+
raise ApplyFailed(
279+
{"detail": _REASON_DETAILS.get(reason, _DEFAULT_REASON_DETAIL), "code": reason}
280+
)
263281

264282

265283
def _apply(
266284
*,
267285
investigation: Investigation,
268-
action_request_id: str,
286+
action_request: InvestigationActionRequest,
269287
request: Request,
270288
actor_id: int,
271289
dry_run: bool,
272-
) -> ApplyResult:
290+
) -> InvestigationActionRequest:
273291
investigations = Investigation.objects.select_related("organization")
274292
runs = InvestigationOrchestrationRun.objects.all()
275293
action_requests = InvestigationActionRequest.objects.all()
@@ -279,31 +297,9 @@ def _apply(
279297
action_requests = action_requests.select_for_update()
280298

281299
locked = investigations.get(id=investigation.id)
282-
try:
283-
run = runs.get(investigation=locked)
284-
except InvestigationOrchestrationRun.DoesNotExist:
285-
raise ResourceDoesNotExist
286-
try:
287-
action_request = action_requests.get(id=action_request_id, investigation=locked)
288-
except (InvestigationActionRequest.DoesNotExist, ValueError):
289-
raise ResourceDoesNotExist
290-
291-
reason = shared_cannot_apply_reason(locked, action_request)
292-
if reason == CannotApplyReason.NOT_SUGGESTED:
293-
return ApplyResult(action_request, _not_suggested_error(action_request))
294-
kind = get_action_kind(action_request.action_kind)
295-
if reason is None and kind is not None:
296-
# The same check that decides canApply guards the endpoint, so a kind can't forget it.
297-
reason = kind.cannot_apply_reasons(
298-
[action_request],
299-
investigations={locked.id: locked},
300-
access=request.access,
301-
user=request.user,
302-
).get(action_request.id, CannotApplyReason.UNSUPPORTED_ACTION)
303-
if reason is not None:
304-
detail = _REASON_DETAILS.get(reason, "This action can't be applied.")
305-
return ApplyResult(action_request, {"detail": detail, "code": reason})
306-
assert kind is not None
300+
run = runs.get(investigation=locked)
301+
action_request = action_requests.get(id=action_request.id)
302+
kind = _check_can_apply(locked, action_request, request)
307303

308304
context = ApplyContext(
309305
investigation=locked,
@@ -315,7 +311,7 @@ def _apply(
315311
try:
316312
if dry_run:
317313
kind.check(context)
318-
return ApplyResult(action_request)
314+
return action_request
319315
details = kind.apply(context)
320316
except ActionStale as stale:
321317
if not dry_run:
@@ -325,23 +321,21 @@ def _apply(
325321
state=InvestigationActionRequestState.SUPERSEDED,
326322
error=ActionRequestError(code=ApplyErrorCode.STALE, current=stale.current),
327323
)
328-
return ApplyResult(
329-
action_request,
324+
raise ApplyFailed(
330325
{
331326
"detail": "This changed since Seer suggested it.",
332327
"code": ApplyErrorCode.STALE,
333328
"current": stale.current,
334-
},
329+
}
335330
)
336331
except ActionInvalid as invalid:
337332
# The request stays suggested, so the user can try again.
338-
return ApplyResult(
339-
action_request,
333+
raise ApplyFailed(
340334
{
341335
"detail": "The change wasn't valid.",
342336
"code": ApplyErrorCode.VALIDATION_FAILED,
343-
"messages": _error_messages(invalid.errors),
344-
},
337+
"messages": invalid.messages,
338+
}
345339
)
346340
action_request.update(
347341
state=InvestigationActionRequestState.APPLIED,
@@ -350,7 +344,7 @@ def _apply(
350344
applied_details=details,
351345
error=None,
352346
)
353-
return ApplyResult(action_request)
347+
return action_request
354348

355349

356350
def apply_action_request(
@@ -360,21 +354,33 @@ def apply_action_request(
360354
request: Request,
361355
actor_id: int,
362356
dry_run: bool = False,
363-
) -> ApplyResult:
364-
"""Apply a suggested action as the requesting user. A dry run only says whether it would
365-
work, and doesn't take locks or change anything.
357+
) -> InvestigationActionRequest:
358+
"""Apply a suggested action as the requesting user, or raise ApplyFailed. A dry run only
359+
says whether it would work, and doesn't take locks or change anything.
366360
"""
367361

362+
try:
363+
action_request = InvestigationActionRequest.objects.get(
364+
id=action_request_id, investigation=investigation
365+
)
366+
except (InvestigationActionRequest.DoesNotExist, ValueError):
367+
raise ResourceDoesNotExist
368+
368369
using = router.db_for_write(InvestigationActionRequest)
369370
with transaction.atomic(using=using):
370-
result = _apply(
371-
investigation=investigation,
372-
action_request_id=action_request_id,
373-
request=request,
374-
actor_id=actor_id,
375-
dry_run=dry_run,
376-
)
377-
if dry_run:
378-
# Even if a check writes by mistake, a dry run keeps nothing.
379-
transaction.set_rollback(True, using=using)
380-
return result
371+
try:
372+
return _apply(
373+
investigation=investigation,
374+
action_request=action_request,
375+
request=request,
376+
actor_id=actor_id,
377+
dry_run=dry_run,
378+
)
379+
except ApplyFailed as failed:
380+
error = failed
381+
finally:
382+
if dry_run:
383+
# The detector validator can create environments while validating.
384+
transaction.set_rollback(True, using=using)
385+
# Raise once the transaction commits, so a stale request stays superseded.
386+
raise error

‎tests/sentry/investigations/endpoints/test_organization_investigation_action_request_apply.py‎

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -207,24 +207,6 @@ def test_a_dry_run_runs_the_same_checks(self) -> None:
207207
assert response.status_code == 409
208208
assert response.data["code"] == "not_suggested"
209209

210-
def test_a_dry_run_changes_nothing(self) -> None:
211-
action_request = self.create_investigation_action_request(
212-
investigation=self.investigation,
213-
key="task",
214-
action_kind="seer_task",
215-
action_payload={"kind": "seer_task", "prompt": "Go"},
216-
)
217-
version = self.investigation.version
218-
219-
response = self.apply(action_request, dryRun=True)
220-
221-
assert response.status_code == 400
222-
assert response.data["code"] == "unsupported_action"
223-
action_request.refresh_from_db()
224-
self.investigation.refresh_from_db()
225-
assert action_request.state == InvestigationActionRequestState.SUGGESTED
226-
assert self.investigation.version == version
227-
228210
def test_rejects_an_unknown_field(self) -> None:
229211
response = self.apply(self.suggest(), dryRn=True)
230212

@@ -350,6 +332,16 @@ def test_a_dry_run_only_checks(self) -> None:
350332
self.action_request.refresh_from_db()
351333
assert self.action_request.state == InvestigationActionRequestState.SUGGESTED
352334

335+
def test_a_dry_run_keeps_nothing_a_check_writes(self) -> None:
336+
title = self.action_request.title
337+
self.kind.check_title = "Changed by the check"
338+
339+
response = self.post(dryRun=True)
340+
341+
assert response.status_code == 200, response.data
342+
self.action_request.refresh_from_db()
343+
assert self.action_request.title == title
344+
353345
def test_applying_records_what_the_kind_returns(self) -> None:
354346
response = self.post()
355347

@@ -386,7 +378,7 @@ def test_a_stale_dry_run_changes_nothing(self) -> None:
386378
assert self.action_request.error is None
387379

388380
def test_an_invalid_action_stays_suggested(self) -> None:
389-
self.kind.raises = ActionInvalid({"window": ["Pick a supported window."]})
381+
self.kind.raises = ActionInvalid(["Pick a supported window."])
390382

391383
response = self.post()
392384

0 commit comments

Comments
 (0)