Skip to content

Commit 8ffaf66

Browse files
authored
fix: make errors from openbis more descriptive (#1143)
1 parent b61f867 commit 8ffaf66

6 files changed

Lines changed: 141 additions & 66 deletions

File tree

components/renku_data_services/errors/errors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,14 @@ class RequestCancelledError(ProgrammingError):
186186
)
187187

188188

189+
@dataclass
190+
class ThirdPartyAPIError(ProgrammingError):
191+
"""Raised when a response from a third party API is not what we expected."""
192+
193+
code: int = 1514
194+
message: str = "An external API or service that Renku depends on is not available or is not behaving as expected."
195+
196+
189197
def missing_or_unauthorized(resource_type: str | StrEnum, id: str | int | ULID) -> MissingResourceError:
190198
"""Generate a missing resource error with an ambiguous message."""
191199
return MissingResourceError(

components/renku_data_services/users/core.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,31 @@
22

33
from datetime import datetime
44

5+
from renku_data_services.errors import errors
56
from renku_data_services.secrets.models import SecretKind, SecretPatch, UnsavedSecret
67
from renku_data_services.users import apispec
78

89

9-
def _validate_expiration_timestamp(expiration_timestamp: datetime | None) -> bool:
10-
return expiration_timestamp is None or expiration_timestamp.tzinfo is not None
10+
def _validate_expiration_timestamp(expiration_timestamp: datetime | None) -> None:
11+
if expiration_timestamp is not None and expiration_timestamp.tzinfo is None:
12+
raise errors.ValidationError(message="The expiration_timestamp has to contain timezone information.")
1113

1214

1315
def validate_unsaved_secret(body: apispec.SecretPost) -> UnsavedSecret:
1416
"""Validate a new secret to be created."""
15-
if _validate_expiration_timestamp(body.expiration_timestamp):
16-
return UnsavedSecret(
17-
name=body.name,
18-
secret_value=body.value,
19-
kind=SecretKind(body.kind.value),
20-
expiration_timestamp=body.expiration_timestamp,
21-
default_filename=body.default_filename,
22-
)
23-
raise ValueError("Expiration timestamp must be specified with a time zone")
17+
_validate_expiration_timestamp(body.expiration_timestamp)
18+
return UnsavedSecret(
19+
name=body.name,
20+
secret_value=body.value,
21+
kind=SecretKind(body.kind.value),
22+
expiration_timestamp=body.expiration_timestamp,
23+
default_filename=body.default_filename,
24+
)
2425

2526

2627
def validate_secret_patch(patch: apispec.SecretPatch) -> SecretPatch:
2728
"""Validate the update to a secret."""
29+
_validate_expiration_timestamp(patch.expiration_timestamp)
2830
return SecretPatch(
2931
name=patch.name,
3032
secret_value=patch.value,

components/renku_data_services/utils/core.py

Lines changed: 117 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ssl
66
from collections.abc import Awaitable, Callable
77
from datetime import datetime, timedelta
8+
from json import JSONDecodeError
89
from typing import Any, Concatenate, ParamSpec, Protocol, TypeVar, cast
910
from zoneinfo import ZoneInfo
1011

@@ -13,6 +14,9 @@
1314
from sqlalchemy.ext.asyncio import AsyncSession
1415

1516
from renku_data_services import errors
17+
from renku_data_services.app_config import logging
18+
19+
logger = logging.getLogger(__name__)
1620

1721

1822
@functools.lru_cache(1)
@@ -83,91 +87,152 @@ async def transaction_wrapper(self: _WithSessionMaker, *args: _P.args, **kwargs:
8387
return transaction_wrapper
8488

8589

86-
def _get_url(host: str) -> str:
87-
return f"https://{host}/openbis/openbis/rmi-application-server-v3.json"
90+
def _get_openbis_url(openbis_host: str) -> str:
91+
return f"https://{openbis_host}/openbis/openbis/rmi-application-server-v3.json"
8892

8993

90-
async def _get_openbis_session_token(host: str, login: dict[str, Any], timeout: int) -> str:
94+
async def _get_openbis_session_token(openbis_host: str, login: dict[str, Any], timeout: int) -> str:
9195
async with httpx.AsyncClient(verify=get_ssl_context(), timeout=5) as client:
92-
response = await client.post(_get_url(host), json=login, timeout=timeout)
93-
if response.status_code == 200:
96+
response = await client.post(_get_openbis_url(openbis_host), json=login, timeout=timeout)
97+
if response.status_code != 200:
98+
raise errors.ThirdPartyAPIError(
99+
detail="OpenBIS responded with a non-200 status code when attempting to get a session token."
100+
)
101+
try:
94102
json: dict[str, str] = response.json()
95-
if "result" in json and json["result"] is not None:
96-
return json["result"]
97-
# No session token was returned. Username and password may be incorrect.
98-
raise errors.GeneralBadRequest()
99-
100-
# An openBIS session token related request failed.
101-
raise errors.BaseError()
103+
except JSONDecodeError as err:
104+
raise errors.ThirdPartyAPIError(
105+
detail="Did not receive a json-formatted output when attempting to get a session token from OpenBIS."
106+
) from err
107+
if json.get("result") is None:
108+
raise errors.ThirdPartyAPIError(
109+
detail="The response from OpenBIS was parsed but it does not contain the exepected field(s) "
110+
"when attempting to get a session token."
111+
)
112+
return json["result"]
102113

103114

104115
async def get_openbis_session_token_for_anonymous_user(
105-
host: str,
116+
openbis_host: str,
106117
timeout: int = 12,
107118
) -> str:
108119
"""Requests an openBIS session token for the anonymous user."""
109120
return await _get_openbis_session_token(
110-
host, {"method": "loginAsAnonymousUser", "params": [], "id": "1", "jsonrpc": "2.0"}, timeout
121+
openbis_host, {"method": "loginAsAnonymousUser", "params": [], "id": "1", "jsonrpc": "2.0"}, timeout
111122
)
112123

113124

114125
async def get_openbis_session_token(
115-
host: str,
126+
openbis_host: str,
116127
username: str,
117128
password: str,
118129
timeout: int = 12,
119130
) -> str:
120131
"""Requests an openBIS session token with the user's login credentials."""
121132
return await _get_openbis_session_token(
122-
host, {"method": "login", "params": [username, password], "id": "2", "jsonrpc": "2.0"}, timeout
133+
openbis_host, {"method": "login", "params": [username, password], "id": "2", "jsonrpc": "2.0"}, timeout
123134
)
124135

125136

126137
async def get_openbis_pat(
127-
host: str,
138+
openbis_host: str,
128139
session_id: str,
129140
personal_access_token_session_name: str = "renku",
130141
minimum_validity_in_days: int = 2,
131142
timeout: int = 12,
132143
) -> tuple[str, datetime]:
133144
"""Requests an openBIS PAT with an openBIS session ID."""
134-
url = _get_url(host)
145+
url = _get_openbis_url(openbis_host)
135146

136147
async with httpx.AsyncClient(verify=get_ssl_context(), timeout=5) as client:
137148
get_server_information = {"method": "getServerInformation", "params": [session_id], "id": "2", "jsonrpc": "2.0"}
138149
response = await client.post(url, json=get_server_information, timeout=timeout)
139-
if response.status_code == 200:
150+
if response.status_code != 200:
151+
logger.error(
152+
f"Received a non-200 response, {response.status_code} from OpenBIS "
153+
f"for performing 'getServerInformation'. Reponse content: {response.text}"
154+
)
155+
raise errors.ThirdPartyAPIError(
156+
detail="OpenBIS responded with a non-200 status code when performing 'getServerInformation'."
157+
)
158+
try:
140159
json1: dict[str, dict[str, str]] = response.json()
141-
if "error" not in json1:
142-
personal_access_tokens_max_validity_period = int(
143-
json1["result"]["personal-access-tokens-max-validity-period"]
144-
)
145-
valid_from = datetime.now(ZoneInfo("Europe/Berlin"))
146-
valid_to = valid_from + timedelta(seconds=personal_access_tokens_max_validity_period)
147-
validity_in_days = (valid_to - valid_from).days
148-
if validity_in_days >= minimum_validity_in_days:
149-
create_personal_access_tokens = {
150-
"method": "createPersonalAccessTokens",
151-
"params": [
152-
session_id,
153-
{
154-
"@type": "as.dto.pat.create.PersonalAccessTokenCreation",
155-
"sessionName": personal_access_token_session_name,
156-
"validFromDate": int(valid_from.timestamp() * 1000),
157-
"validToDate": int(valid_to.timestamp() * 1000),
158-
},
159-
],
160-
"id": "2",
161-
"jsonrpc": "2.0",
162-
}
163-
response = await client.post(url, json=create_personal_access_tokens, timeout=timeout)
164-
if response.status_code == 200:
165-
json2: dict[str, list[dict[str, str]]] = response.json()
166-
return json2["result"][0]["permId"], valid_to
167-
else:
168-
# The maximum allowed validity period of a personal access token is less than
169-
# "minimum_validity_in_days" days.
170-
raise errors.GeneralBadRequest()
171-
172-
# An openBIS personal access token related request failed.
173-
raise errors.BaseError()
160+
except JSONDecodeError as err:
161+
logger.error(
162+
f"Could not parse OpenBIS response for performing 'getServerInformation' into JSON. "
163+
f"Response content: {response.text}"
164+
)
165+
raise errors.ThirdPartyAPIError(
166+
detail="Could not parse OpenBIS response about server information into JSON."
167+
) from err
168+
if "error" in json1:
169+
raise errors.ThirdPartyAPIError(
170+
detail=f"The response from OpenBIS for 'getServerInformation' contained errors: {json1['error']}."
171+
)
172+
if json1.get("result", {}).get("personal-access-tokens-max-validity-period") is None:
173+
logger.error(
174+
f"The response from OpenBIS for 'getServerInformation' did not contain the expected "
175+
"token validity period fields. "
176+
f"Response content: {response.text}"
177+
)
178+
raise errors.ThirdPartyAPIError(
179+
detail="The response from OpenBIS for 'getServerInformation' "
180+
"did not contain the expected token validity period."
181+
)
182+
personal_access_tokens_max_validity_period = int(json1["result"]["personal-access-tokens-max-validity-period"])
183+
valid_from = datetime.now(ZoneInfo("Europe/Berlin"))
184+
valid_to = valid_from + timedelta(seconds=personal_access_tokens_max_validity_period)
185+
validity_in_days = (valid_to - valid_from).days
186+
if validity_in_days < minimum_validity_in_days:
187+
raise errors.ThirdPartyAPIError(
188+
detail="The allowed validity of the personal access token from OpenBIS is shorter "
189+
f"than the required minimum validity of {minimum_validity_in_days} days"
190+
)
191+
create_personal_access_tokens = {
192+
"method": "createPersonalAccessTokens",
193+
"params": [
194+
session_id,
195+
{
196+
"@type": "as.dto.pat.create.PersonalAccessTokenCreation",
197+
"sessionName": personal_access_token_session_name,
198+
"validFromDate": int(valid_from.timestamp() * 1000),
199+
"validToDate": int(valid_to.timestamp() * 1000),
200+
},
201+
],
202+
"id": "2",
203+
"jsonrpc": "2.0",
204+
}
205+
response = await client.post(url, json=create_personal_access_tokens, timeout=timeout)
206+
if response.status_code != 200:
207+
logger.error(
208+
"OpenBIS responded with a non-200 status code when creating a personal access token. "
209+
f"Status code: {response.status_code}, response content: {response.text}"
210+
)
211+
raise errors.ThirdPartyAPIError(
212+
detail="OpenBIS responded with a non-200 status code when creating a personal access token."
213+
)
214+
try:
215+
json2: dict[str, list[dict[str, str]]] = response.json()
216+
except JSONDecodeError as err:
217+
logger.error(
218+
"Could not parse OpenBIS response for creating a personal access token into JSON."
219+
f"Response content: {response.text}"
220+
)
221+
raise errors.ThirdPartyAPIError(
222+
detail="Could not parse OpenBIS response for creating personal access token into JSON."
223+
) from err
224+
if (
225+
not isinstance(json2.get("result"), list)
226+
or len(json2["result"]) == 0
227+
or json2["result"][0].get("permId") is None
228+
):
229+
logger.error(
230+
"The response from OpenBIS did not have the required 'result[0].permId' field in the response "
231+
"from creating a personal access token. "
232+
f"Response content: {response.text}"
233+
)
234+
raise errors.ThirdPartyAPIError(
235+
detail="The response from OpenBIS did not have the required 'result[0].permId' field in the response "
236+
"from creating a personal access token."
237+
)
238+
return json2["result"][0]["permId"], valid_to

test/bases/renku_data_services/data_api/test_data_connectors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1640,7 +1640,7 @@ async def test_delete_data_connector_secrets(
16401640
@pytest.mark.asyncio
16411641
async def test_create_openbis_data_connector(sanic_client, create_openbis_data_connector, user_headers) -> None:
16421642
openbis_session_token = await get_openbis_session_token_for_anonymous_user(
1643-
host="openbis-eln-lims.ethz.ch", # Public openBIS demo instance.
1643+
openbis_host="openbis-eln-lims.ethz.ch", # Public openBIS demo instance.
16441644
)
16451645
data_connector = await create_openbis_data_connector(
16461646
"openBIS data connector 1", session_token=openbis_session_token

test/bases/renku_data_services/data_api/test_secret.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ async def test_get_update_a_secret(sanic_client: SanicASGITestClient, user_heade
239239
_, response = await sanic_client.patch(
240240
f"/api/data/user/secrets/{secret["id"]}",
241241
headers=user_headers,
242-
json={"value": "newest-value", "expiration_timestamp": "2029-12-31"},
242+
json={"value": "newest-value", "expiration_timestamp": "2029-12-31T00:00:00Z"},
243243
)
244244
assert response.status_code == 200, response.text
245245

test/bases/renku_data_services/data_api/test_storage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ async def test_storage_validate_connection(storage_test_client) -> None:
604604
@pytest.mark.asyncio
605605
async def test_openbis_storage_validate_connection(storage_test_client) -> None:
606606
openbis_session_token = await get_openbis_session_token(
607-
host="openbis-eln-lims.ethz.ch", # Public openBIS demo instance.
607+
openbis_host="openbis-eln-lims.ethz.ch", # Public openBIS demo instance.
608608
username="observer",
609609
password="1234",
610610
)

0 commit comments

Comments
 (0)