Skip to content

Commit 1506cb9

Browse files
authored
Add retry, error handling for agamemnon and blueapi requests (#1690)
* Add retry, error handling for agamemnon and blueapi requests * Add alerting functionality when unable to talk to blueapi or agamemnon
1 parent b6c4a97 commit 1506cb9

12 files changed

Lines changed: 327 additions & 22 deletions

File tree

docs/user/hyperion/advanced/alerts.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ The currently supported events that will generate alerts are:
1515
- When Hyperion moves on to a new container (puck).
1616
- When Hyperion is paused waiting for beam for more than 10 minutes (xbpm_feedback position stable. Repeats every 10 minutes)
1717
- When Hyperion data collection takes longer than 10 minutes (as measured from the last wait-for-beam check)
18+
- When Hyperion supervisor is unable to communicate with hyperion-blueapi
19+
- When Hyperion is unable to fetch the next instruction from agamemnon
1820

1921
Graylog Alert Configuration
2022
===========================

src/mx_bluesky/common/external_interaction/alerting/_service.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from typing import Protocol
44
from urllib.parse import quote, urlencode
55

6+
from dodal.utils import get_beamline_name
7+
68

79
class Metadata(StrEnum):
810
"""Metadata fields that can be specified by the caller when raising an alert."""
@@ -47,6 +49,20 @@ def raise_alert(
4749
"""
4850
pass
4951

52+
def raise_error_alert(self, content: str, metadata: dict[Metadata, str]):
53+
"""
54+
Raise an alert that will be forwarded to beamline staff and EHC controllers for out-of-hours
55+
support.
56+
Args:
57+
content: Plain text content detailing the nature of the incident.
58+
metadata: A dict of strings that can be included as metadata in the alert for
59+
those backends that support it. The summary and content will be included
60+
by default.
61+
62+
"""
63+
beamline = get_beamline_name()
64+
self.raise_alert(f"UDC encountered an error on {beamline}", content, metadata)
65+
5066

5167
_alert_service: AlertService
5268

src/mx_bluesky/common/external_interaction/alerting/log_based_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from mx_bluesky.common.external_interaction.alerting import Metadata
88
from mx_bluesky.common.external_interaction.alerting._service import (
9+
AlertService,
910
ExtraMetadata,
1011
graylog_url,
1112
ispyb_url,
@@ -15,7 +16,7 @@
1516
)
1617

1718

18-
class LoggingAlertService:
19+
class LoggingAlertService(AlertService):
1920
"""
2021
Implement an alert service that raises alerts by generating a specially formatted
2122
log message, that may be intercepted by a logging service such as graylog and

src/mx_bluesky/common/external_interaction/callbacks/sample_handling/sample_handling_callback.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from dodal.utils import get_beamline_name
21
from event_model import RunStart, RunStop
32

43
from mx_bluesky.common.external_interaction.alerting import (
@@ -70,9 +69,7 @@ def _record_exception(
7069
sample_status = self._decode_sample_status(exception_type)
7170
expeye.update_sample_status(self._sample_id, sample_status)
7271
if sample_status == BLSampleStatus.ERROR_BEAMLINE:
73-
beamline = get_beamline_name("")
74-
get_alerting_service().raise_alert(
75-
f"UDC encountered an error on {beamline}",
72+
get_alerting_service().raise_error_alert(
7673
f"Hyperion encountered the following beamline error: {reason}",
7774
{
7875
Metadata.SAMPLE_ID: str(self._sample_id),

src/mx_bluesky/hyperion/external_interaction/agamemnon.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import os
33
import re
4+
import time
45
from collections.abc import Sequence
56
from enum import StrEnum
67
from os import path
@@ -9,7 +10,9 @@
910
import requests
1011
from dodal.utils import get_beamline_name
1112
from pydantic import BaseModel
13+
from requests import ConnectionError, HTTPError, Response, Timeout
1214

15+
from mx_bluesky.common.external_interaction.alerting import get_alerting_service
1316
from mx_bluesky.common.parameters.components import (
1417
WithVisit,
1518
)
@@ -28,13 +31,17 @@
2831
from mx_bluesky.hyperion.external_interaction.config_server import (
2932
get_hyperion_feature_settings,
3033
)
34+
from mx_bluesky.hyperion.plan_runner import PlanError
3135

3236
T = TypeVar("T", bound=WithVisit)
3337
MULTIPIN_PREFIX = "multipin"
3438
MULTIPIN_FORMAT_DESC = "Expected multipin format is multipin_{number_of_wells}x{well_size}+{distance_between_tip_and_first_well}"
3539
MULTIPIN_REGEX = rf"^{MULTIPIN_PREFIX}_(\d+)x(\d+(?:\.\d+)?)\+(\d+(?:\.\d+)?)$"
3640
MX_GENERAL_ROOT_REGEX = r"^/dls/(?P<beamline>[^/]+)/data/[^/]*/(?P<visit>[^/]+)(?:/|$)"
3741

42+
MAX_TRIES = 3
43+
RETRY_INITIAL_DELAY_S = 2
44+
3845

3946
class _InstructionType(StrEnum):
4047
WAIT = "wait"
@@ -46,7 +53,10 @@ def create_parameters_from_agamemnon() -> Sequence[BaseModel]:
4653
mx-bluesky instructions.
4754
Returns:
4855
The generated sequence of mx-bluesky parameters, or empty list if
49-
no instructions."""
56+
no instructions.
57+
Raises:
58+
PlanError: if the instructions could not be fetched from Agamemnon
59+
"""
5060
beamline_name = get_beamline_name("i03")
5161
agamemnon_instruction = _get_next_instruction(beamline_name)
5262
if agamemnon_instruction:
@@ -75,11 +85,46 @@ def _instruction_and_data(agamemnon_instruction: dict) -> tuple[str, Any]:
7585

7686

7787
def _get_parameters_from_url(url: str) -> dict:
78-
response = requests.get(url, headers={"Accept": "application/json"})
79-
response.raise_for_status()
88+
tries, delay = MAX_TRIES, RETRY_INITIAL_DELAY_S
89+
while tries > 0:
90+
tries -= 1
91+
try:
92+
response = requests.get(url, headers={"Accept": "application/json"})
93+
try:
94+
response.raise_for_status()
95+
break
96+
except HTTPError as e:
97+
if _is_server_error(response):
98+
LOGGER.warning(
99+
f"Agamemnon returned server error status {response.status_code}, retries left {tries}: {str(e)}"
100+
)
101+
else:
102+
msg = f"Agamemnon returned unexpected HTTP response status code {response.status_code}"
103+
get_alerting_service().raise_error_alert(msg, {})
104+
raise PlanError(msg) from e
105+
except ConnectionError as e:
106+
LOGGER.warning(
107+
f"Connection error attempting to connect to agamemnon, retries left {tries}",
108+
exc_info=e,
109+
)
110+
except Timeout:
111+
LOGGER.warning(
112+
f"Timed out attempting to connect to agamemnon, retries left {tries}"
113+
)
114+
if tries:
115+
time.sleep(delay) # noqa
116+
delay *= 2
117+
else:
118+
msg = f"Unable to fetch instruction from agamemnon after {MAX_TRIES} attempts, ending UDC."
119+
get_alerting_service().raise_error_alert(msg, {})
120+
raise PlanError(msg)
80121
return json.loads(response.content)
81122

82123

124+
def _is_server_error(response: Response):
125+
return 500 <= response.status_code < 600
126+
127+
83128
def _get_pin_type_from_agamemnon_collect_parameters(
84129
collect_parameters: dict,
85130
) -> PinTypeParam:

src/mx_bluesky/hyperion/supervisor/_supervisor.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import time
12
from collections.abc import Sequence
23

34
from blueapi.client.client import BlueapiClient
45
from blueapi.client.event_bus import BlueskyStreamingError
6+
from blueapi.client.rest import ServiceUnavailableError
57
from blueapi.config import ApplicationConfig
68
from blueapi.core import BlueskyContext
79
from blueapi.service.model import TaskRequest
@@ -10,6 +12,7 @@
1012
from bluesky.utils import MsgGenerator
1113
from pydantic import BaseModel
1214

15+
from mx_bluesky.common.external_interaction.alerting import get_alerting_service
1316
from mx_bluesky.common.parameters.constants import Status
1417
from mx_bluesky.common.utils.exceptions import CrystalNotFoundError, SampleError
1518
from mx_bluesky.common.utils.log import LOGGER
@@ -23,6 +26,9 @@
2326
from mx_bluesky.hyperion.plan_runner import PlanError, PlanRunner
2427
from mx_bluesky.hyperion.supervisor._task_monitor import TaskMonitor
2528

29+
MAX_TRIES = 3
30+
RETRY_INITIAL_DELAY_S = 2
31+
2632

2733
class SupervisorRunner(PlanRunner):
2834
"""Runner that executes plans by delegating to a remote blueapi instance"""
@@ -125,9 +131,35 @@ def shutdown(self):
125131
def _run_task_remotely(self, task_request: TaskRequest):
126132
try:
127133
with TaskMonitor(self.blueapi_client, task_request) as task_monitor:
128-
task_status = self.blueapi_client.run_task(
129-
task_request, on_event=task_monitor.on_blueapi_event
130-
)
134+
tries, task_status, delay = MAX_TRIES, None, RETRY_INITIAL_DELAY_S
135+
while tries > 0:
136+
tries -= 1
137+
try:
138+
task_status = self.blueapi_client.run_task(
139+
task_request, on_event=task_monitor.on_blueapi_event
140+
)
141+
break
142+
except ServiceUnavailableError as e:
143+
LOGGER.warning(
144+
"Could not connect to blueapi client.", exc_info=e
145+
)
146+
time.sleep(delay) # noqa
147+
delay += delay
148+
except BlueskyStreamingError:
149+
raise
150+
except Exception as e:
151+
msg = "Unexpected error communicating with hyperion-blueapi"
152+
get_alerting_service().raise_error_alert(msg, {})
153+
raise PlanError(msg) from e
154+
else:
155+
LOGGER.error("Max retries reached, ending UDC.")
156+
get_alerting_service().raise_error_alert(
157+
"UDC was stopped because hyperion-supervisor was unable to connect to hyperion-blueapi.",
158+
{},
159+
)
160+
raise PlanError(
161+
f"Unable to connect to hyperion-blueapi after {MAX_TRIES} attempts, ending UDC"
162+
)
131163
LOGGER.info(
132164
f"hyperion-blueapi completed task execution with task_status {task_status}"
133165
)

src/mx_bluesky/hyperion/supervisor/_task_monitor.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,7 @@ def _reset_timer(self):
8686
self._timer.start()
8787

8888
def _raise_alert_collection_is_stuck(self):
89-
beamline = get_beamline_name("")
90-
self._alerting_service.raise_alert(
91-
f"UDC encountered an error on {beamline}",
89+
self._alerting_service.raise_error_alert(
9290
f"Hyperion Supervisor detected that BlueAPI was stuck for {self.DEFAULT_TIMEOUT_S} seconds.",
9391
self._extract_metadata(),
9492
)

tests/unit_tests/common/external_interaction/alerting/alerting/test_alerting.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,25 @@ def test_logging_alerting_service_raises_a_log_message_with_additional_metadata_
8484
Metadata.VISIT: "cm14451-2",
8585
},
8686
)
87+
88+
89+
@patch("mx_bluesky.common.external_interaction.alerting.log_based_service.LOGGER")
90+
def test_raise_error_alert_delegates_to_raise_alert(mock_logger: MagicMock):
91+
set_alerting_service(LoggingAlertService(CONST.GRAYLOG_STREAM_ID, WARNING))
92+
get_alerting_service().raise_error_alert(
93+
"Test message", {Metadata.SAMPLE_ID: "123456", Metadata.VISIT: "cm14451-2"}
94+
)
95+
mock_logger.log.assert_called_once_with(
96+
WARNING,
97+
"***ALERT*** summary=UDC encountered an error on i03 content=Test message",
98+
extra={
99+
ExtraMetadata.ALERT_SUMMARY: "UDC encountered an error on i03",
100+
ExtraMetadata.ALERT_CONTENT: "Test message",
101+
ExtraMetadata.BEAMLINE: "i03",
102+
ExtraMetadata.GRAYLOG_URL: EXPECTED_GRAYLOG_URL,
103+
ExtraMetadata.ISPYB_URL: "https://ispyb.diamond.ac.uk/samples/sid/123456",
104+
Metadata.SAMPLE_ID: "123456",
105+
ExtraMetadata.PROPOSAL: "cm14451",
106+
Metadata.VISIT: "cm14451-2",
107+
},
108+
)

tests/unit_tests/common/external_interaction/callbacks/sample_handling/test_sample_handling_callback.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,7 @@ def test_sample_handling_callback_raises_an_alert_when_beamline_error_occurs(
230230
run_engine(plan_with_general_exception(exception_type, message))
231231

232232
if expect_alert:
233-
mock_alert_service.raise_alert.assert_called_once_with(
234-
"UDC encountered an error on i03",
233+
mock_alert_service.raise_error_alert.assert_called_once_with(
235234
f"Hyperion encountered the following beamline error: {message}",
236235
{
237236
Metadata.SAMPLE_ID: str(TEST_SAMPLE_ID),

0 commit comments

Comments
 (0)