diff --git a/services/api-server/src/simcore_service_api_server/api/errors/custom_errors.py b/services/api-server/src/simcore_service_api_server/api/errors/custom_errors.py index 972151de789..73d59598ca6 100644 --- a/services/api-server/src/simcore_service_api_server/api/errors/custom_errors.py +++ b/services/api-server/src/simcore_service_api_server/api/errors/custom_errors.py @@ -1,7 +1,6 @@ import logging -from urllib.request import Request -from fastapi import status +from fastapi import Request, status from servicelib.error_codes import create_error_code from starlette.responses import JSONResponse diff --git a/services/api-server/src/simcore_service_api_server/api/errors/httpx_client_error.py b/services/api-server/src/simcore_service_api_server/api/errors/httpx_client_error.py index 10e0ae2273e..01d2954244a 100644 --- a/services/api-server/src/simcore_service_api_server/api/errors/httpx_client_error.py +++ b/services/api-server/src/simcore_service_api_server/api/errors/httpx_client_error.py @@ -1,50 +1,39 @@ """ General handling of httpx-based exceptions - httpx-based clients are used to communicate with other backend services - - When those respond with 4XX, 5XX status codes, those are generally handled here + - any exception raised by a httpx client will be handled here. """ import logging +from typing import Any -from fastapi import status -from httpx import HTTPStatusError -from starlette.requests import Request -from starlette.responses import JSONResponse - -from .http_error import create_error_json_response +from fastapi import HTTPException, Request, status +from httpx import HTTPError, TimeoutException _logger = logging.getLogger(__file__) -async def httpx_client_error_handler(_: Request, exc: HTTPStatusError) -> JSONResponse: +async def handle_httpx_client_exceptions(_: Request, exc: HTTPError): """ - This is called when HTTPStatusError was raised and reached the outermost handler - - This handler is used as a "last resource" since it is recommended to handle these exceptions - closer to the raising point. - - The response had an error HTTP status of 4xx or 5xx, and this is how is - transformed in the api-server API + Default httpx exception handler. + See https://www.python-httpx.org/exceptions/ + With this in place only HTTPStatusErrors need to be customized closer to the httpx client itself. """ - if exc.response.is_client_error: - assert exc.response.is_server_error # nosec - # Forward api-server's client from backend client errors - status_code = exc.response.status_code - errors = exc.response.json()["errors"] + status_code: Any + detail: str + headers: dict[str, str] = {} + if isinstance(exc, TimeoutException): + status_code = status.HTTP_504_GATEWAY_TIMEOUT + detail = f"Request to {exc.request.url.host.capitalize()} timed out" else: - assert exc.response.is_server_error # nosec - # Hide api-server's client from backend server errors - status_code = status.HTTP_503_SERVICE_UNAVAILABLE - message = f"{exc.request.url.host.capitalize()} service unexpectedly failed" - errors = [ - message, - ] + status_code = status.HTTP_502_BAD_GATEWAY + detail = f"{exc.request.url.host.capitalize()} service unexpectedly failed" + if status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR: _logger.exception( - "%s. host=%s status-code=%s msg=%s", - message, + "%s. host=%s", + detail, exc.request.url.host, - exc.response.status_code, - exc.response.text, ) - - return create_error_json_response(*errors, status_code=status_code) + raise HTTPException( + status_code=status_code, detail=detail, headers=headers + ) from exc diff --git a/services/api-server/src/simcore_service_api_server/api/errors/validation_error.py b/services/api-server/src/simcore_service_api_server/api/errors/validation_error.py index 23aaa1d0f4e..c40ed5db47f 100644 --- a/services/api-server/src/simcore_service_api_server/api/errors/validation_error.py +++ b/services/api-server/src/simcore_service_api_server/api/errors/validation_error.py @@ -1,9 +1,9 @@ +from fastapi import Request from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.openapi.constants import REF_PREFIX from fastapi.openapi.utils import validation_error_response_definition from pydantic import ValidationError -from starlette.requests import Request from starlette.responses import JSONResponse from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers.py index ebcc483ac49..cf165966715 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers.py @@ -151,7 +151,7 @@ async def list_solver_releases( SEE get_solver_releases_page for a paginated version of this function """ releases: list[Solver] = await catalog_client.list_solver_releases( - user_id, solver_key, product_name=product_name + user_id=user_id, solver_key=solver_key, product_name=product_name ) for solver in releases: @@ -189,7 +189,7 @@ async def get_solver_release( ) -> Solver: """Gets a specific release of a solver""" try: - solver = await catalog_client.get_service( + solver: Solver = await catalog_client.get_service( user_id=user_id, name=solver_key, version=version, diff --git a/services/api-server/src/simcore_service_api_server/api/routes/studies.py b/services/api-server/src/simcore_service_api_server/api/routes/studies.py index c8ceffa12e2..4fb45588439 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/studies.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/studies.py @@ -1,5 +1,5 @@ import logging -from typing import Annotated, Any, Final +from typing import Annotated, Final from fastapi import APIRouter, Depends, status from fastapi_pagination.api import create_page @@ -124,11 +124,10 @@ async def list_study_ports( New in *version 0.5.0* (only with API_SERVER_DEV_FEATURES_ENABLED=1) """ try: - project_ports: list[ - dict[str, Any] - ] = await webserver_api.get_project_metadata_ports(project_id=study_id) - - return OnePage[StudyPort](items=project_ports) # type: ignore[arg-type] + project_ports: list[StudyPort] = await webserver_api.get_project_metadata_ports( + project_id=study_id + ) + return OnePage[StudyPort](items=project_ports) except ProjectNotFoundError: return create_error_json_response( diff --git a/services/api-server/src/simcore_service_api_server/api/routes/users.py b/services/api-server/src/simcore_service_api_server/api/routes/users.py index 9bcf094c38a..5183a641342 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/users.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/users.py @@ -17,8 +17,8 @@ async def get_my_profile( webserver_session: Annotated[AuthSession, Depends(get_webserver_session)], ) -> Profile: - data = await webserver_session.get("/me") - return Profile.parse_obj(data) + profile: Profile = await webserver_session.get_me() + return profile @router.put("", response_model=Profile) @@ -28,6 +28,5 @@ async def update_my_profile( AuthSession, Security(get_webserver_session, scopes=["write"]) ], ) -> Profile: - await webserver_session.put("/me", body=profile_update.dict(exclude_none=True)) - profile: Profile = await get_my_profile(webserver_session) + profile: Profile = await webserver_session.update_me(profile_update) return profile diff --git a/services/api-server/src/simcore_service_api_server/core/application.py b/services/api-server/src/simcore_service_api_server/core/application.py index 17d13b2fd6a..7f31f2ecf52 100644 --- a/services/api-server/src/simcore_service_api_server/core/application.py +++ b/services/api-server/src/simcore_service_api_server/core/application.py @@ -3,7 +3,7 @@ from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi_pagination import add_pagination -from httpx import HTTPStatusError +from httpx import HTTPError as HttpxException from models_library.basic_types import BootModeEnum from servicelib.fastapi.prometheus_instrumentation import ( setup_prometheus_instrumentation, @@ -24,7 +24,7 @@ http_error_handler, make_http_error_handler_for_exception, ) -from ..api.errors.httpx_client_error import httpx_client_error_handler +from ..api.errors.httpx_client_error import handle_httpx_client_exceptions from ..api.errors.validation_error import http422_error_handler from ..api.root import create_router from ..api.routes.health import router as health_router @@ -102,8 +102,8 @@ def init_app(settings: ApplicationSettings | None = None) -> FastAPI: app.add_event_handler("shutdown", create_stop_app_handler(app)) app.add_exception_handler(HTTPException, http_error_handler) + app.add_exception_handler(HttpxException, handle_httpx_client_exceptions) app.add_exception_handler(RequestValidationError, http422_error_handler) - app.add_exception_handler(HTTPStatusError, httpx_client_error_handler) app.add_exception_handler(LogDistributionBaseException, log_handling_error_handler) app.add_exception_handler(CustomBaseError, custom_error_handler) diff --git a/services/api-server/src/simcore_service_api_server/services/_service_exception_handling.py b/services/api-server/src/simcore_service_api_server/services/_service_exception_handling.py new file mode 100644 index 00000000000..c6a953a799a --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/services/_service_exception_handling.py @@ -0,0 +1,75 @@ +import logging +from contextlib import contextmanager +from functools import wraps +from typing import Any, Callable, Mapping + +import httpx +from fastapi import HTTPException, status +from pydantic import ValidationError +from servicelib.error_codes import create_error_code + +_logger = logging.getLogger(__name__) + + +def service_exception_mapper( + service_name: str, + http_status_map: Mapping[int, tuple[int, Callable[[Any], str] | None]], +): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + with backend_service_exception_handler( + service_name, http_status_map, **kwargs + ): + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +@contextmanager +def backend_service_exception_handler( + service_name: str, + http_status_map: Mapping[int, tuple[int, Callable[[dict], str] | None]], + **endpoint_kwargs, +): + try: + yield + except ValidationError as exc: + error_code = create_error_code(exc) + _logger.exception( + "Invalid data exchanged with %s service [%s] ", + service_name, + error_code, + extra={"error_code": error_code}, + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"{service_name} service returned invalid response. {error_code}", + ) from exc + + except httpx.HTTPStatusError as exc: + if status_detail_tuple := http_status_map.get(exc.response.status_code): + status_code, detail_callback = status_detail_tuple + if detail_callback is None: + detail = f"{exc}" + else: + detail = detail_callback(endpoint_kwargs) + raise HTTPException(status_code=status_code, detail=detail) from exc + if exc.response.status_code in { + status.HTTP_503_SERVICE_UNAVAILABLE, + status.HTTP_429_TOO_MANY_REQUESTS, + }: + headers = {} + if "Retry-After" in exc.response.headers: + headers["Retry-After"] = exc.response.headers["Retry-After"] + raise HTTPException( + status_code=exc.response.status_code, + detail=f"The {service_name} service was unavailable", + headers=headers, + ) from exc + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Received unexpected response from {service_name}", + ) from exc diff --git a/services/api-server/src/simcore_service_api_server/services/catalog.py b/services/api-server/src/simcore_service_api_server/services/catalog.py index dcb2007b527..f3f45d28687 100644 --- a/services/api-server/src/simcore_service_api_server/services/catalog.py +++ b/services/api-server/src/simcore_service_api_server/services/catalog.py @@ -1,18 +1,21 @@ +import asyncio import logging import urllib.parse from collections.abc import Callable from dataclasses import dataclass +from functools import partial from operator import attrgetter -from fastapi import FastAPI +from fastapi import FastAPI, status from models_library.emails import LowerCaseEmailStr from models_library.services import ServiceDockerData, ServiceType -from pydantic import Extra, ValidationError, parse_obj_as +from pydantic import Extra, ValidationError, parse_obj_as, parse_raw_as from settings_library.catalog import CatalogSettings from ..models.basic_types import VersionStr from ..models.schemas.solvers import LATEST_VERSION, Solver, SolverKeyId, SolverPort from ..utils.client_base import BaseServiceClientApi, setup_client_instance +from ._service_exception_handling import service_exception_mapper _logger = logging.getLogger(__name__) @@ -61,6 +64,8 @@ def to_solver(self) -> Solver: # - Error handling: What do we reraise, suppress, transform??? # +_exception_mapper = partial(service_exception_mapper, "Catalog") + @dataclass class CatalogApi(BaseServiceClientApi): @@ -71,10 +76,18 @@ class CatalogApi(BaseServiceClientApi): SEE osparc-simcore/services/catalog/openapi.json """ + @_exception_mapper( + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: "Could not list solvers/studies", + ) + } + ) async def list_solvers( self, - user_id: int, *, + user_id: int, product_name: str, predicate: Callable[[Solver], bool] | None = None, ) -> list[Solver]: @@ -85,10 +98,14 @@ async def list_solvers( ) response.raise_for_status() + services: list[ + TruncatedCatalogServiceOut + ] = await asyncio.get_event_loop().run_in_executor( + None, parse_raw_as, list[TruncatedCatalogServiceOut], response.text + ) solvers = [] - for data in response.json(): + for service in services: try: - service = TruncatedCatalogServiceOut.parse_obj(data) if service.service_type == ServiceType.COMPUTATIONAL: solver = service.to_solver() if predicate is None or predicate(solver): @@ -100,13 +117,21 @@ async def list_solvers( # invalid items instead of returning error _logger.warning( "Skipping invalid service returned by catalog '%s': %s", - data, + service.json(), err, ) return solvers + @_exception_mapper( + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: f"Could not get solver/study {kwargs['name']}:{kwargs['version']}", + ) + } + ) async def get_service( - self, user_id: int, name: SolverKeyId, version: VersionStr, *, product_name: str + self, *, user_id: int, name: SolverKeyId, version: VersionStr, product_name: str ) -> Solver: assert version != LATEST_VERSION # nosec @@ -121,7 +146,11 @@ async def get_service( ) response.raise_for_status() - service = TruncatedCatalogServiceOut.parse_obj(response.json()) + service: ( + TruncatedCatalogServiceOut + ) = await asyncio.get_event_loop().run_in_executor( + None, parse_raw_as, TruncatedCatalogServiceOut, response.text + ) assert ( # nosec service.service_type == ServiceType.COMPUTATIONAL ), "Expected by SolverName regex" @@ -129,8 +158,16 @@ async def get_service( solver: Solver = service.to_solver() return solver + @_exception_mapper( + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: f"Could not get ports for solver/study {kwargs['name']}:{kwargs['version']}", + ) + } + ) async def get_service_ports( - self, user_id: int, name: SolverKeyId, version: VersionStr, *, product_name: str + self, *, user_id: int, name: SolverKeyId, version: VersionStr, product_name: str ): assert version != LATEST_VERSION # nosec @@ -149,10 +186,10 @@ async def get_service_ports( return parse_obj_as(list[SolverPort], response.json()) async def list_latest_releases( - self, user_id: int, *, product_name: str + self, *, user_id: int, product_name: str ) -> list[Solver]: solvers: list[Solver] = await self.list_solvers( - user_id, product_name=product_name + user_id=user_id, product_name=product_name ) latest_releases: dict[SolverKeyId, Solver] = {} @@ -164,13 +201,13 @@ async def list_latest_releases( return list(latest_releases.values()) async def list_solver_releases( - self, user_id: int, solver_key: SolverKeyId, *, product_name: str + self, *, user_id: int, solver_key: SolverKeyId, product_name: str ) -> list[Solver]: def _this_solver(solver: Solver) -> bool: return solver.id == solver_key releases: list[Solver] = await self.list_solvers( - user_id, predicate=_this_solver, product_name=product_name + user_id=user_id, predicate=_this_solver, product_name=product_name ) return releases @@ -178,7 +215,7 @@ async def get_latest_release( self, user_id: int, solver_key: SolverKeyId, *, product_name: str ) -> Solver: releases = await self.list_solver_releases( - user_id, solver_key, product_name=product_name + user_id=user_id, solver_key=solver_key, product_name=product_name ) # raises IndexError if None diff --git a/services/api-server/src/simcore_service_api_server/services/director_v2.py b/services/api-server/src/simcore_service_api_server/services/director_v2.py index dbb18f12569..186530b4d6a 100644 --- a/services/api-server/src/simcore_service_api_server/services/director_v2.py +++ b/services/api-server/src/simcore_service_api_server/services/director_v2.py @@ -1,11 +1,9 @@ import logging -from contextlib import contextmanager +from functools import partial from typing import Any, ClassVar from uuid import UUID from fastapi import FastAPI -from fastapi.exceptions import HTTPException -from httpx import HTTPStatusError, codes from models_library.clusters import ClusterID from models_library.projects_nodes import NodeID from models_library.projects_pipeline import ComputationTask @@ -19,6 +17,7 @@ from ..core.settings import DirectorV2Settings from ..models.schemas.jobs import PercentageInt from ..utils.client_base import BaseServiceClientApi, setup_client_instance +from ._service_exception_handling import service_exception_mapper logger = logging.getLogger(__name__) @@ -64,42 +63,11 @@ class TaskLogFileGet(BaseModel): # API CLASS --------------------------------------------- - -@contextmanager -def _handle_errors_context(project_id: UUID): - try: - yield - - # except ValidationError - except HTTPStatusError as err: - msg = ( - f"Failed {err.request.url} with status={err.response.status_code}: {err.response.json()}", - ) - if codes.is_client_error(err.response.status_code): - # client errors are mapped - logger.debug(msg) - if err.response.status_code == status.HTTP_404_NOT_FOUND: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Job {project_id} not found", - ) from err - - raise err - - # server errors are logged and re-raised as 503 - assert codes.is_server_error(err.response.status_code) # nosec - - logger.exception( - "director-v2 service failed: %s. Re-rasing as service unavailable (503)", - msg, - ) - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Director service failed", - ) from err +_exception_mapper = partial(service_exception_mapper, "Director V2") class DirectorV2Api(BaseServiceClientApi): + @_exception_mapper({}) async def create_computation( self, project_id: UUID, @@ -116,8 +84,10 @@ async def create_computation( }, ) response.raise_for_status() - return ComputationTaskGet(**response.json()) + task: ComputationTaskGet = ComputationTaskGet.parse_raw(response.text) + return task + @_exception_mapper({}) async def start_computation( self, project_id: UUID, @@ -126,32 +96,40 @@ async def start_computation( groups_extra_properties_repository: GroupsExtraPropertiesRepository, cluster_id: ClusterID | None = None, ) -> ComputationTaskGet: - with _handle_errors_context(project_id): - extras = {} + extras = {} - use_on_demand_clusters = ( - await groups_extra_properties_repository.use_on_demand_clusters( - user_id, product_name - ) + use_on_demand_clusters = ( + await groups_extra_properties_repository.use_on_demand_clusters( + user_id, product_name ) + ) - if cluster_id is not None and not use_on_demand_clusters: - extras["cluster_id"] = cluster_id - - response = await self.client.post( - "/v2/computations", - json={ - "user_id": user_id, - "project_id": str(project_id), - "start_pipeline": True, - "product_name": product_name, - "use_on_demand_clusters": use_on_demand_clusters, - **extras, - }, - ) - response.raise_for_status() - return ComputationTaskGet(**response.json()) + if cluster_id is not None and not use_on_demand_clusters: + extras["cluster_id"] = cluster_id + response = await self.client.post( + "/v2/computations", + json={ + "user_id": user_id, + "project_id": str(project_id), + "start_pipeline": True, + "product_name": product_name, + "use_on_demand_clusters": use_on_demand_clusters, + **extras, + }, + ) + response.raise_for_status() + task: ComputationTaskGet = ComputationTaskGet.parse_raw(response.text) + return task + + @_exception_mapper( + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: f"Could not get solver/study job {kwargs['project_id']}", + ) + } + ) async def get_computation( self, project_id: UUID, user_id: PositiveInt ) -> ComputationTaskGet: @@ -162,8 +140,17 @@ async def get_computation( }, ) response.raise_for_status() - return ComputationTaskGet(**response.json()) - + task: ComputationTaskGet = ComputationTaskGet.parse_raw(response.text) + return task + + @_exception_mapper( + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: f"Could not get solver/study job {kwargs['project_id']}", + ) + } + ) async def stop_computation( self, project_id: UUID, user_id: PositiveInt ) -> ComputationTaskGet: @@ -173,11 +160,20 @@ async def stop_computation( "user_id": user_id, }, ) - - return ComputationTaskGet(**response.json()) - + response.raise_for_status() + task: ComputationTaskGet = ComputationTaskGet.parse_raw(response.text) + return task + + @_exception_mapper( + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: f"Could not get solver/study job {kwargs['project_id']}", + ) + } + ) async def delete_computation(self, project_id: UUID, user_id: PositiveInt): - await self.client.request( + response = await self.client.request( "DELETE", f"/v2/computations/{project_id}", json={ @@ -185,7 +181,16 @@ async def delete_computation(self, project_id: UUID, user_id: PositiveInt): "force": True, }, ) + response.raise_for_status() + @_exception_mapper( + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: f"Could not get logfile for solver/study job {kwargs['project_id']}", + ) + } + ) async def get_computation_logs( self, user_id: PositiveInt, project_id: UUID ) -> dict[NodeName, DownloadLink]: diff --git a/services/api-server/src/simcore_service_api_server/services/storage.py b/services/api-server/src/simcore_service_api_server/services/storage.py index 3f4b51403d3..2675d8784fa 100644 --- a/services/api-server/src/simcore_service_api_server/services/storage.py +++ b/services/api-server/src/simcore_service_api_server/services/storage.py @@ -1,6 +1,7 @@ import logging import re import urllib.parse +from functools import partial from mimetypes import guess_type from typing import Literal from uuid import UUID @@ -17,9 +18,11 @@ from ..core.settings import StorageSettings from ..models.schemas.files import File from ..utils.client_base import BaseServiceClientApi, setup_client_instance +from ._service_exception_handling import service_exception_mapper _logger = logging.getLogger(__name__) +_exception_mapper = partial(service_exception_mapper, "Storage") _FILE_ID_PATTERN = re.compile(r"^api\/(?P[\w-]+)\/(?P.+)$") AccessRight = Literal["read", "write"] @@ -49,6 +52,7 @@ class StorageApi(BaseServiceClientApi): # SIMCORE_S3_ID = 0 + @_exception_mapper({}) async def list_files(self, user_id: int) -> list[StorageFileMetaData]: """Lists metadata of all s3 objects name as api/* from a given user""" @@ -63,10 +67,13 @@ async def list_files(self, user_id: int) -> list[StorageFileMetaData]: ) response.raise_for_status() - files_metadata = FileMetaDataArray(__root__=response.json()["data"] or []) - files: list[StorageFileMetaData] = files_metadata.__root__ + files_metadata = Envelope[FileMetaDataArray].parse_raw(response.text).data + files: list[StorageFileMetaData] = ( + [] if files_metadata is None else files_metadata.__root__ + ) return files + @_exception_mapper({}) async def search_files( self, *, @@ -91,11 +98,15 @@ async def search_files( "/simcore-s3/files/metadata:search", params={k: v for k, v in params.items() if v is not None}, ) + response.raise_for_status() - files_metadata = FileMetaDataArray(__root__=response.json()["data"] or []) - files: list[StorageFileMetaData] = files_metadata.__root__ + files_metadata = Envelope[FileMetaDataArray].parse_raw(response.text).data + files: list[StorageFileMetaData] = ( + [] if files_metadata is None else files_metadata.__root__ + ) return files + @_exception_mapper({}) async def get_download_link( self, user_id: int, file_id: UUID, file_name: str ) -> AnyUrl: @@ -105,11 +116,16 @@ async def get_download_link( f"/locations/{self.SIMCORE_S3_ID}/files/{object_path}", params={"user_id": str(user_id)}, ) + response.raise_for_status() - presigned_link: PresignedLink = PresignedLink.parse_obj(response.json()["data"]) + presigned_link: PresignedLink | None = ( + Envelope[PresignedLink].parse_raw(response.text).data + ) + assert presigned_link is not None link: AnyUrl = presigned_link.link return link + @_exception_mapper({}) async def delete_file(self, user_id: int, quoted_storage_file_id: str) -> None: response = await self.client.delete( f"/locations/{self.SIMCORE_S3_ID}/files/{quoted_storage_file_id}", @@ -117,6 +133,7 @@ async def delete_file(self, user_id: int, quoted_storage_file_id: str) -> None: ) response.raise_for_status() + @_exception_mapper({}) async def get_upload_links( self, user_id: int, file_id: UUID, file_name: str ) -> FileUploadSchema: @@ -129,7 +146,7 @@ async def get_upload_links( ) response.raise_for_status() - enveloped_data = Envelope[FileUploadSchema].parse_obj(response.json()) + enveloped_data = Envelope[FileUploadSchema].parse_raw(response.text) assert enveloped_data.data # nosec return enveloped_data.data @@ -153,6 +170,7 @@ async def create_abort_upload_link( url = url.include_query_params(**query) return url + @_exception_mapper({}) async def create_soft_link( self, user_id: int, target_s3_path: str, as_file_id: UUID ) -> File: @@ -174,7 +192,8 @@ async def create_soft_link( ) response.raise_for_status() - stored_file_meta = StorageFileMetaData.parse_obj(response.json()["data"]) + stored_file_meta = Envelope[StorageFileMetaData].parse_raw(response.text).data + assert stored_file_meta is not None file_meta: File = to_file_api_model(stored_file_meta) return file_meta diff --git a/services/api-server/src/simcore_service_api_server/services/webserver.py b/services/api-server/src/simcore_service_api_server/services/webserver.py index 5fd727f0560..e34a70a4763 100644 --- a/services/api-server/src/simcore_service_api_server/services/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services/webserver.py @@ -1,17 +1,15 @@ # pylint: disable=R0904 -import json import logging import urllib.parse -from contextlib import contextmanager from dataclasses import dataclass -from typing import Any +from functools import partial +from typing import Any, Mapping from uuid import UUID -import httpx from cryptography import fernet -from fastapi import FastAPI, HTTPException -from httpx import Response +from fastapi import FastAPI +from models_library.api_schemas_long_running_tasks.tasks import TaskGet from models_library.api_schemas_webserver.computations import ComputationStart from models_library.api_schemas_webserver.product import GetCreditPrice from models_library.api_schemas_webserver.projects import ProjectCreateNew, ProjectGet @@ -33,11 +31,11 @@ from models_library.projects import ProjectID from models_library.rest_pagination import Page from models_library.utils.fastapi_encoders import jsonable_encoder -from pydantic import PositiveInt, ValidationError +from pydantic import PositiveInt from pydantic.errors import PydanticErrorMixin from servicelib.aiohttp.long_running_tasks.server import TaskStatus -from servicelib.error_codes import create_error_code from simcore_service_api_server.models.schemas.solvers import SolverKeyId +from simcore_service_api_server.models.schemas.studies import StudyPort from starlette import status from tenacity import TryAgain from tenacity._asyncio import AsyncRetrying @@ -49,8 +47,12 @@ from ..models.basic_types import VersionStr from ..models.pagination import MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE from ..models.schemas.jobs import MetaValueType -from ..models.types import AnyJson +from ..models.schemas.profiles import Profile, ProfileUpdate from ..utils.client_base import BaseServiceClientApi, setup_client_instance +from ._service_exception_handling import ( + backend_service_exception_handler, + service_exception_mapper, +) _logger = logging.getLogger(__name__) @@ -64,48 +66,27 @@ class ProjectNotFoundError(WebServerValueError): msg_template = "Project '{project_id}' not found" -@contextmanager -def _handle_webserver_api_errors(): - # Transforms httpx.errors and ValidationError -> fastapi.HTTPException - try: - yield +_exception_mapper = partial(service_exception_mapper, "Webserver") - except ValidationError as exc: - # Invalid formatted response body - error_code = create_error_code(exc) - _logger.exception( - "Invalid data exchanged with webserver service [%s]", - error_code, - extra={"error_code": error_code}, - ) - raise HTTPException( - status.HTTP_503_SERVICE_UNAVAILABLE, detail=error_code - ) from exc - - except httpx.RequestError as exc: - # e.g. TransportError, DecodingError, TooManyRedirects - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE) from exc - - except httpx.HTTPStatusError as exc: - resp = exc.response - if resp.is_server_error: - _logger.exception( - "webserver reponded with an error: %s [%s]", - f"{resp.status_code=}", - f"{resp.reason_phrase=}", - ) - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE) from exc +_JOB_STATUS_MAP: Mapping = { + status.HTTP_402_PAYMENT_REQUIRED: (status.HTTP_402_PAYMENT_REQUIRED, None), + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: f"The job/study {kwargs['project_id']} could not be found", + ), +} - if resp.is_client_error: - # NOTE: Raise ProjectErrors / WebserverError that should be transformed into HTTP errors on the handler level - error = exc.response.json().get("error", {}) - msg = error.get("errors") or resp.reason_phrase or f"{exc}" - raise HTTPException(resp.status_code, detail=msg) from exc +_PROFILE_STATUS_MAP: Mapping = { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: "Could not find profile", + ) +} - except ProjectNotFoundError as exc: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) - ) from exc +_WALLET_STATUS_MAP: Mapping = { + status.HTTP_404_NOT_FOUND: (status.HTTP_404_NOT_FOUND, None), + status.HTTP_403_FORBIDDEN: (status.HTTP_403_FORBIDDEN, None), +} class WebserverApi(BaseServiceClientApi): @@ -149,90 +130,12 @@ def create( session_cookies=session_cookies, ) - @classmethod - def _get_data_or_raise( - cls, - resp: Response, - client_status_code_to_exception_map: dict[int, WebServerValueError] - | None = None, - ) -> AnyJson | None: - """ - Raises: - WebServerValueError: any client error converted to module error - HTTPException: the rest are pre-process and raised as http errors - - """ - # enveloped answer - data: AnyJson | None = None - error: AnyJson | None = None - - if resp.status_code != status.HTTP_204_NO_CONTENT: - try: - body = resp.json() - data, error = body.get("data"), body.get("error") - except json.JSONDecodeError: - _logger.warning( - "Failed to unenvelop webserver response %s", - f"{resp.text=}", - exc_info=True, - ) - - if resp.is_server_error: - _logger.error( - "webserver reponded with an error: %s [%s]: %s", - f"{resp.status_code=}", - f"{resp.reason_phrase=}", - error, - ) - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE) - - if resp.is_client_error: - # Maps client status code to webserver local module error - if client_status_code_to_exception_map and ( - exc := client_status_code_to_exception_map.get(resp.status_code) - ): - raise exc - - # Otherwise, go thru with some pre-processing to make - # message cleaner - if isinstance(error, dict): - error = error.get("message") - - msg = error or resp.reason_phrase - raise HTTPException(resp.status_code, detail=msg) - - return data - # OPERATIONS @property def client(self): return self._api.client - async def get(self, path: str) -> AnyJson | None: - url = path.lstrip("/") - try: - resp = await self.client.get(url, cookies=self.session_cookies) - except Exception as err: - _logger.exception("Failed to get %s", url) - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE) from err - - return self._get_data_or_raise(resp) - - async def put(self, path: str, body: dict) -> AnyJson | None: - url = path.lstrip("/") - try: - resp = await self.client.put( - url, - json=body, - cookies=self.session_cookies, - ) - except Exception as err: - _logger.exception("Failed to put %s", url) - raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE) from err - - return self._get_data_or_raise(resp) - async def _page_projects( self, *, limit: int, offset: int, show_hidden: bool, search: str | None = None ): @@ -243,7 +146,15 @@ async def _page_projects( if search is not None: optional["search"] = search - with _handle_webserver_api_errors(): + with backend_service_exception_handler( + "Webserver", + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: "Could not list jobs", + ) + }, + ): resp = await self.client.get( "/projects", params={ @@ -259,10 +170,10 @@ async def _page_projects( return Page[ProjectGet].parse_raw(resp.text) - async def _wait_for_long_running_task_results(self, data): + async def _wait_for_long_running_task_results(self, data: TaskGet): # NOTE: /v0 is already included in the http client base_url - status_url = data["status_href"].lstrip(f"/{self.vtag}") - result_url = data["result_href"].lstrip(f"/{self.vtag}") + status_url = data.status_href.lstrip(f"/{self.vtag}") + result_url = data.result_href.lstrip(f"/{self.vtag}") # GET task status now until done async for attempt in AsyncRetrying( @@ -272,16 +183,46 @@ async def _wait_for_long_running_task_results(self, data): before_sleep=before_sleep_log(_logger, logging.INFO), ): with attempt: - status_data = await self.get(status_url) - task_status = TaskStatus.parse_obj(status_data) + get_response = await self.client.get( + status_url, cookies=self.session_cookies + ) + get_response.raise_for_status() + task_status = Envelope[TaskStatus].parse_raw(get_response.text).data + assert task_status is not None if not task_status.done: msg = "Timed out creating project. TIP: Try again, or contact oSparc support if this is happening repeatedly" raise TryAgain(msg) - return await self.get(f"{result_url}") + result_response = await self.client.get( + f"{result_url}", cookies=self.session_cookies + ) + result_response.raise_for_status() + return Envelope.parse_raw(result_response.text).data + + # PROFILE -------------------------------------------------- + + @_exception_mapper(_PROFILE_STATUS_MAP) + async def get_me(self) -> Profile: + response = await self.client.get("/me", cookies=self.session_cookies) + response.raise_for_status() + profile: Profile | None = Envelope[Profile].parse_raw(response.text).data + assert profile is not None + return profile + + @_exception_mapper(_PROFILE_STATUS_MAP) + async def update_me(self, profile_update: ProfileUpdate) -> Profile: + response = await self.client.put( + "/me", + json=profile_update.dict(exclude_none=True), + cookies=self.session_cookies, + ) + response.raise_for_status() + profile: Profile = await self.get_me() + return profile # PROJECTS ------------------------------------------------- + @_exception_mapper({}) async def create_project(self, project: ProjectCreateNew) -> ProjectGet: # POST /projects --> 202 Accepted response = await self.client.post( @@ -290,36 +231,36 @@ async def create_project(self, project: ProjectCreateNew) -> ProjectGet: json=jsonable_encoder(project, by_alias=True, exclude={"state"}), cookies=self.session_cookies, ) - data = self._get_data_or_raise(response) + response.raise_for_status() + data = Envelope[TaskGet].parse_raw(response.text).data assert data is not None # nosec result = await self._wait_for_long_running_task_results(data) return ProjectGet.parse_obj(result) + @_exception_mapper(_JOB_STATUS_MAP) async def clone_project(self, project_id: UUID) -> ProjectGet: response = await self.client.post( f"/projects/{project_id}:clone", cookies=self.session_cookies, ) - data = self._get_data_or_raise( - response, - {status.HTTP_404_NOT_FOUND: ProjectNotFoundError(project_id=project_id)}, - ) + response.raise_for_status() + data = Envelope[TaskGet].parse_raw(response.text).data assert data is not None # nosec result = await self._wait_for_long_running_task_results(data) return ProjectGet.parse_obj(result) + @_exception_mapper(_JOB_STATUS_MAP) async def get_project(self, project_id: UUID) -> ProjectGet: - with _handle_webserver_api_errors(): - response = await self.client.get( - f"/projects/{project_id}", - cookies=self.session_cookies, - ) - response.raise_for_status() - data = Envelope[ProjectGet].parse_raw(response.text).data - assert data is not None - return data + response = await self.client.get( + f"/projects/{project_id}", + cookies=self.session_cookies, + ) + response.raise_for_status() + data = Envelope[ProjectGet].parse_raw(response.text).data + assert data is not None + return data async def get_projects_w_solver_page( self, solver_name: str, limit: int, offset: int @@ -340,20 +281,25 @@ async def get_projects_page(self, limit: int, offset: int): show_hidden=False, ) + @_exception_mapper(_JOB_STATUS_MAP) async def delete_project(self, project_id: ProjectID) -> None: response = await self.client.delete( f"/projects/{project_id}", cookies=self.session_cookies, ) - data = self._get_data_or_raise( - response, - {status.HTTP_404_NOT_FOUND: ProjectNotFoundError(project_id=project_id)}, - ) - assert data is None # nosec + response.raise_for_status() + @_exception_mapper( + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: f"The ports for the job/study {kwargs['project_id']} could not be found", + ) + } + ) async def get_project_metadata_ports( self, project_id: ProjectID - ) -> list[dict[str, Any]]: + ) -> list[StudyPort]: """ maps GET "/projects/{study_id}/metadata/ports", unenvelopes and returns data @@ -362,53 +308,65 @@ async def get_project_metadata_ports( f"/projects/{project_id}/metadata/ports", cookies=self.session_cookies, ) - - data = self._get_data_or_raise( - response, - {status.HTTP_404_NOT_FOUND: ProjectNotFoundError(project_id=project_id)}, - ) + response.raise_for_status() + data = Envelope[list[StudyPort]].parse_raw(response.text).data assert data is not None assert isinstance(data, list) return data - async def get_project_metadata(self, project_id: ProjectID) -> ProjectMetadataGet: - with _handle_webserver_api_errors(): - response = await self.client.get( - f"/projects/{project_id}/metadata", - cookies=self.session_cookies, + @_exception_mapper( + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: f"The metadata for the job/study {kwargs['project_id']} could not be found", ) - response.raise_for_status() - data = Envelope[ProjectMetadataGet].parse_raw(response.text).data - assert data # nosec - return data + } + ) + async def get_project_metadata(self, project_id: ProjectID) -> ProjectMetadataGet: + response = await self.client.get( + f"/projects/{project_id}/metadata", + cookies=self.session_cookies, + ) + response.raise_for_status() + data = Envelope[ProjectMetadataGet].parse_raw(response.text).data + assert data # nosec + return data + @_exception_mapper( + { + status.HTTP_404_NOT_FOUND: ( + status.HTTP_404_NOT_FOUND, + lambda kwargs: f"The metadata for the job/study {kwargs['project_id']} could not be found", + ) + } + ) async def update_project_metadata( self, project_id: ProjectID, metadata: dict[str, MetaValueType] ) -> ProjectMetadataGet: - with _handle_webserver_api_errors(): - response = await self.client.patch( - f"/projects/{project_id}/metadata", - cookies=self.session_cookies, - json=jsonable_encoder(ProjectMetadataUpdate(custom=metadata)), - ) - response.raise_for_status() - data = Envelope[ProjectMetadataGet].parse_raw(response.text).data - assert data # nosec - return data + response = await self.client.patch( + f"/projects/{project_id}/metadata", + cookies=self.session_cookies, + json=jsonable_encoder(ProjectMetadataUpdate(custom=metadata)), + ) + response.raise_for_status() + data = Envelope[ProjectMetadataGet].parse_raw(response.text).data + assert data # nosec + return data + @_exception_mapper({status.HTTP_404_NOT_FOUND: (status.HTTP_404_NOT_FOUND, None)}) async def get_project_node_pricing_unit( self, project_id: UUID, node_id: UUID ) -> PricingUnitGet | None: - with _handle_webserver_api_errors(): - response = await self.client.get( - f"/projects/{project_id}/nodes/{node_id}/pricing-unit", - cookies=self.session_cookies, - ) + response = await self.client.get( + f"/projects/{project_id}/nodes/{node_id}/pricing-unit", + cookies=self.session_cookies, + ) - response.raise_for_status() - data = Envelope[PricingUnitGet].parse_raw(response.text).data - return data + response.raise_for_status() + data = Envelope[PricingUnitGet].parse_raw(response.text).data + return data + @_exception_mapper({status.HTTP_404_NOT_FOUND: (status.HTTP_404_NOT_FOUND, None)}) async def connect_pricing_unit_to_project_node( self, project_id: UUID, @@ -416,90 +374,89 @@ async def connect_pricing_unit_to_project_node( pricing_plan: PositiveInt, pricing_unit: PositiveInt, ) -> None: - with _handle_webserver_api_errors(): - response = await self.client.put( - f"/projects/{project_id}/nodes/{node_id}/pricing-plan/{pricing_plan}/pricing-unit/{pricing_unit}", - cookies=self.session_cookies, - ) - response.raise_for_status() + response = await self.client.put( + f"/projects/{project_id}/nodes/{node_id}/pricing-plan/{pricing_plan}/pricing-unit/{pricing_unit}", + cookies=self.session_cookies, + ) + response.raise_for_status() + @_exception_mapper(_JOB_STATUS_MAP) async def start_project( self, project_id: UUID, cluster_id: ClusterID | None = None ) -> None: - with _handle_webserver_api_errors(): - body_input: dict[str, Any] = {} - if cluster_id: - body_input["cluster_id"] = cluster_id - body: ComputationStart = ComputationStart(**body_input) - response = await self.client.post( - f"/computations/{project_id}:start", - cookies=self.session_cookies, - json=jsonable_encoder(body, exclude_unset=True, exclude_defaults=True), - ) - response.raise_for_status() + body_input: dict[str, Any] = {} + if cluster_id: + body_input["cluster_id"] = cluster_id + body: ComputationStart = ComputationStart(**body_input) + response = await self.client.post( + f"/computations/{project_id}:start", + cookies=self.session_cookies, + json=jsonable_encoder(body, exclude_unset=True, exclude_defaults=True), + ) + response.raise_for_status() # WALLETS ------------------------------------------------- + @_exception_mapper(_WALLET_STATUS_MAP) async def get_default_wallet(self) -> WalletGetWithAvailableCredits: - with _handle_webserver_api_errors(): - response = await self.client.get( - "/wallets/default", - cookies=self.session_cookies, - ) - response.raise_for_status() - data = Envelope[WalletGetWithAvailableCredits].parse_raw(response.text).data - assert data # nosec - return data + response = await self.client.get( + "/wallets/default", + cookies=self.session_cookies, + ) + response.raise_for_status() + data = Envelope[WalletGetWithAvailableCredits].parse_raw(response.text).data + assert data # nosec + return data + @_exception_mapper(_WALLET_STATUS_MAP) async def get_wallet(self, wallet_id: int) -> WalletGetWithAvailableCredits: - with _handle_webserver_api_errors(): - response = await self.client.get( - f"/wallets/{wallet_id}", - cookies=self.session_cookies, - ) - response.raise_for_status() - data = Envelope[WalletGetWithAvailableCredits].parse_raw(response.text).data - assert data # nosec - return data + response = await self.client.get( + f"/wallets/{wallet_id}", + cookies=self.session_cookies, + ) + response.raise_for_status() + data = Envelope[WalletGetWithAvailableCredits].parse_raw(response.text).data + assert data # nosec + return data + @_exception_mapper(_WALLET_STATUS_MAP) async def get_project_wallet(self, project_id: ProjectID) -> WalletGet | None: - with _handle_webserver_api_errors(): - response = await self.client.get( - f"/projects/{project_id}/wallet", - cookies=self.session_cookies, - ) - response.raise_for_status() - data = Envelope[WalletGet].parse_raw(response.text).data - return data + response = await self.client.get( + f"/projects/{project_id}/wallet", + cookies=self.session_cookies, + ) + response.raise_for_status() + data = Envelope[WalletGet].parse_raw(response.text).data + return data # PRODUCTS ------------------------------------------------- + @_exception_mapper({status.HTTP_404_NOT_FOUND: (status.HTTP_404_NOT_FOUND, None)}) async def get_product_price(self) -> NonNegativeDecimal | None: - with _handle_webserver_api_errors(): - response = await self.client.get( - "/credits-price", - cookies=self.session_cookies, - ) - response.raise_for_status() - data = Envelope[GetCreditPrice].parse_raw(response.text).data - assert data is not None - return data.usd_per_credit + response = await self.client.get( + "/credits-price", + cookies=self.session_cookies, + ) + response.raise_for_status() + data = Envelope[GetCreditPrice].parse_raw(response.text).data + assert data is not None + return data.usd_per_credit # SERVICES ------------------------------------------------- + @_exception_mapper({status.HTTP_404_NOT_FOUND: (status.HTTP_404_NOT_FOUND, None)}) async def get_service_pricing_plan( self, solver_key: SolverKeyId, version: VersionStr ) -> ServicePricingPlanGet | None: service_key = urllib.parse.quote_plus(solver_key) - with _handle_webserver_api_errors(): - response = await self.client.get( - f"/catalog/services/{service_key}/{version}/pricing-plan", - cookies=self.session_cookies, - ) - response.raise_for_status() - data = Envelope[ServicePricingPlanGet].parse_raw(response.text).data - return data + response = await self.client.get( + f"/catalog/services/{service_key}/{version}/pricing-plan", + cookies=self.session_cookies, + ) + response.raise_for_status() + data = Envelope[ServicePricingPlanGet].parse_raw(response.text).data + return data # MODULES APP SETUP ------------------------------------------------------------- diff --git a/services/api-server/tests/unit/api_studies/test_api_routes_studies.py b/services/api-server/tests/unit/api_studies/test_api_routes_studies.py index 3cc92db680a..282a33ba524 100644 --- a/services/api-server/tests/unit/api_studies/test_api_routes_studies.py +++ b/services/api-server/tests/unit/api_studies/test_api_routes_studies.py @@ -103,7 +103,7 @@ async def test_studies_read_workflow( resp = await client.get(f"/v0/studies/{inexistent_study_id}", auth=auth) assert resp.status_code == status.HTTP_404_NOT_FOUND error = parse_obj_as(ErrorGet, resp.json()) - assert f"{inexistent_study_id}" in error.errors[0][0]["message"] + assert f"{inexistent_study_id}" in error.errors[0] resp = await client.get(f"/v0/studies/{inexistent_study_id}/ports", auth=auth) assert resp.status_code == status.HTTP_404_NOT_FOUND @@ -175,4 +175,3 @@ async def test_clone_study_not_found( errors: list[str] = resp.json()["errors"] assert any("WEBSERVER_MARK" not in error_msg for error_msg in errors) - assert any(unknown_study_id in error_msg for error_msg in errors) diff --git a/services/api-server/tests/unit/test_api_solvers.py b/services/api-server/tests/unit/test_api_solvers.py index 3e3a38f7df2..19fbc077221 100644 --- a/services/api-server/tests/unit/test_api_solvers.py +++ b/services/api-server/tests/unit/test_api_solvers.py @@ -17,7 +17,7 @@ [ ( "get_solver_pricing_plan_invalid_solver.json", - status.HTTP_503_SERVICE_UNAVAILABLE, + status.HTTP_502_BAD_GATEWAY, ), ("get_solver_pricing_plan_success.json", status.HTTP_200_OK), ], diff --git a/services/api-server/tests/unit/test_services_utils.py b/services/api-server/tests/unit/test_services_utils.py new file mode 100644 index 00000000000..0b6c0f5323a --- /dev/null +++ b/services/api-server/tests/unit/test_services_utils.py @@ -0,0 +1,32 @@ +import pytest +from fastapi import HTTPException, status +from httpx import HTTPStatusError, Request, Response +from simcore_service_api_server.services._service_exception_handling import ( + service_exception_mapper, +) + + +async def test_backend_service_exception_mapper(): + @service_exception_mapper( + "DummyService", + { + status.HTTP_400_BAD_REQUEST: ( + status.HTTP_200_OK, + lambda kwargs: "error message", + ) + }, + ) + async def my_endpoint(status_code: int): + raise HTTPStatusError( + message="hello", + request=Request("PUT", "https://asoubkjbasd.asjdbnsakjb"), + response=Response(status_code), + ) + + with pytest.raises(HTTPException) as exc_info: + await my_endpoint(status.HTTP_400_BAD_REQUEST) + assert exc_info.value.status_code == status.HTTP_200_OK + + with pytest.raises(HTTPException) as exc_info: + await my_endpoint(status.HTTP_500_INTERNAL_SERVER_ERROR) + assert exc_info.value.status_code == status.HTTP_502_BAD_GATEWAY diff --git a/tests/public-api/requirements/ci.txt b/tests/public-api/requirements/ci.txt index e40aa36450e..f55aec6076e 100644 --- a/tests/public-api/requirements/ci.txt +++ b/tests/public-api/requirements/ci.txt @@ -9,7 +9,7 @@ # installs base + tests requirements --requirement _test.txt -osparc +osparc==0.6.5 # installs this repo's packages ../../packages/pytest-simcore/ diff --git a/tests/public-api/requirements/dev.txt b/tests/public-api/requirements/dev.txt index 4d892418f34..128f4959134 100644 --- a/tests/public-api/requirements/dev.txt +++ b/tests/public-api/requirements/dev.txt @@ -14,7 +14,7 @@ # make install-dev # make python-client # ---editable ../../services/api-server/client +osparc # installs base + tests requirements