From 55ee32edf14def5d9fc57ecdfb282c07643098ef Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 13:21:10 +0200 Subject: [PATCH 01/30] add initial rpc endpoints for celery --- .../simcore_service_api_server/api/root.py | 2 + .../api/routes/tasks.py | 116 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 services/api-server/src/simcore_service_api_server/api/routes/tasks.py diff --git a/services/api-server/src/simcore_service_api_server/api/root.py b/services/api-server/src/simcore_service_api_server/api/root.py index 8c6dbc6de1a4..b5a295ee7283 100644 --- a/services/api-server/src/simcore_service_api_server/api/root.py +++ b/services/api-server/src/simcore_service_api_server/api/root.py @@ -18,6 +18,7 @@ solvers_jobs_read, studies, studies_jobs, + tasks, users, wallets, ) @@ -65,6 +66,7 @@ def create_router(settings: ApplicationSettings): router.include_router( functions_routes.function_router, tags=["functions"], prefix=_FUNCTIONS_PREFIX ) + router.include_router(tasks.router, tags=["tasks"], prefix="/tasks") # NOTE: multiple-files upload is currently disabled # Web form to upload files at http://localhost:8000/v0/upload-form-view diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py new file mode 100644 index 000000000000..6091d28b4029 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -0,0 +1,116 @@ +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, status +from models_library.api_schemas_long_running_tasks.base import TaskProgress +from models_library.api_schemas_long_running_tasks.tasks import ( + TaskGet, + TaskResult, + TaskStatus, +) +from models_library.api_schemas_rpc_async_jobs.async_jobs import ( + AsyncJobId, + AsyncJobNameData, +) +from models_library.api_schemas_storage import STORAGE_RPC_NAMESPACE +from models_library.products import ProductName +from models_library.users import UserID +from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.async_jobs import async_jobs + +from ..dependencies.authentication import get_current_user_id +from ..dependencies.rabbitmq import get_rabbitmq_rpc_client +from ..dependencies.services import get_product_name + +router = APIRouter() +_logger = logging.getLogger(__name__) + + +# Helper to build job_id_data from user context (for demo, expects user_id and product_name as query params) +def _get_job_id_data(user_id: UserID, product_name: ProductName) -> AsyncJobNameData: + return AsyncJobNameData(user_id=user_id, product_name=product_name) + + +@router.get("", response_model=list[TaskGet]) +async def get_async_jobs( + user_id: Annotated[UserID, Depends(get_current_user_id)], + product_name: Annotated[ProductName, Depends(get_product_name)], + rabbitmq_rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], +): + user_async_jobs = await async_jobs.list_jobs( + rabbitmq_rpc_client=rabbitmq_rpc_client, + rpc_namespace=STORAGE_RPC_NAMESPACE, + job_id_data=_get_job_id_data(user_id, product_name), + filter_="", + ) + return [ + TaskGet( + task_id=str(job.job_id), + task_name=job.job_name, + status_href=router.url_path_for( + "get_async_job_status", task_id=str(job.job_id) + ), + abort_href=router.url_path_for("cancel_async_job", task_id=str(job.job_id)), + result_href=router.url_path_for( + "get_async_job_result", task_id=str(job.job_id) + ), + ) + for job in user_async_jobs + ] + + +@router.get("/{task_id}", response_model=TaskStatus, name="get_async_job_status") +async def get_async_job_status( + task_id: AsyncJobId, + user_id: Annotated[UserID, Depends(get_current_user_id)], + product_name: Annotated[ProductName, Depends(get_product_name)], + rabbitmq_rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], +): + async_job_rpc_status = await async_jobs.status( + rabbitmq_rpc_client=rabbitmq_rpc_client, + rpc_namespace=STORAGE_RPC_NAMESPACE, + job_id=task_id, + job_id_data=_get_job_id_data(user_id, product_name), + ) + _task_id = str(async_job_rpc_status.job_id) + return TaskStatus( + task_progress=TaskProgress( + task_id=_task_id, percent=async_job_rpc_status.progress.percent_value + ), + done=async_job_rpc_status.done, + started=None, + ) + + +@router.delete( + "/{task_id}/cancel", status_code=status.HTTP_204_NO_CONTENT, name="cancel_async_job" +) +async def cancel_async_job( + task_id: AsyncJobId, + user_id: Annotated[UserID, Depends(get_current_user_id)], + product_name: Annotated[ProductName, Depends(get_product_name)], + rabbitmq_rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], +): + await async_jobs.cancel( + rabbitmq_rpc_client=rabbitmq_rpc_client, + rpc_namespace=STORAGE_RPC_NAMESPACE, + job_id=task_id, + job_id_data=_get_job_id_data(user_id, product_name), + ) + return + + +@router.get("/{task_id}/result", response_model=TaskResult, name="get_async_job_result") +async def get_async_job_result( + task_id: AsyncJobId, + user_id: Annotated[UserID, Depends(get_current_user_id)], + product_name: Annotated[ProductName, Depends(get_product_name)], + rabbitmq_rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], +): + async_job_rpc_result = await async_jobs.result( + rabbitmq_rpc_client=rabbitmq_rpc_client, + rpc_namespace=STORAGE_RPC_NAMESPACE, + job_id=task_id, + job_id_data=_get_job_id_data(user_id, product_name), + ) + return TaskResult(result=async_job_rpc_result.result, error=None) From 0db7aa2346cefd5f4344ba5e2a2c2d8028e57676 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 13:21:48 +0200 Subject: [PATCH 02/30] follow-up --- .../src/simcore_service_api_server/api/routes/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index 6091d28b4029..bfdcdfb95570 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -97,7 +97,6 @@ async def cancel_async_job( job_id=task_id, job_id_data=_get_job_id_data(user_id, product_name), ) - return @router.get("/{task_id}/result", response_model=TaskResult, name="get_async_job_result") From b26466d2d6964855d9f1ac69166f10cc214a8c9e Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 14:15:56 +0200 Subject: [PATCH 03/30] cleanups --- .../api/routes/tasks.py | 17 +++++++++-------- .../models/schemas/tasks.py | 9 +++++++++ 2 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 services/api-server/src/simcore_service_api_server/models/schemas/tasks.py diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index bfdcdfb95570..f4dcab27b353 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -17,6 +17,7 @@ from models_library.users import UserID from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient from servicelib.rabbitmq.rpc_interfaces.async_jobs import async_jobs +from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope from ..dependencies.authentication import get_current_user_id from ..dependencies.rabbitmq import get_rabbitmq_rpc_client @@ -26,12 +27,11 @@ _logger = logging.getLogger(__name__) -# Helper to build job_id_data from user context (for demo, expects user_id and product_name as query params) def _get_job_id_data(user_id: UserID, product_name: ProductName) -> AsyncJobNameData: return AsyncJobNameData(user_id=user_id, product_name=product_name) -@router.get("", response_model=list[TaskGet]) +@router.get("", response_model=ApiServerEnvelope[list[TaskGet]]) async def get_async_jobs( user_id: Annotated[UserID, Depends(get_current_user_id)], product_name: Annotated[ProductName, Depends(get_product_name)], @@ -43,20 +43,21 @@ async def get_async_jobs( job_id_data=_get_job_id_data(user_id, product_name), filter_="", ) - return [ + data = [ TaskGet( - task_id=str(job.job_id), + task_id=f"{job.job_id}", task_name=job.job_name, status_href=router.url_path_for( - "get_async_job_status", task_id=str(job.job_id) + "get_async_job_status", task_id=f"{job.job_id}" ), - abort_href=router.url_path_for("cancel_async_job", task_id=str(job.job_id)), + abort_href=router.url_path_for("cancel_async_job", task_id=f"{job.job_id}"), result_href=router.url_path_for( - "get_async_job_result", task_id=str(job.job_id) + "get_async_job_result", task_id=f"{job.job_id}" ), ) for job in user_async_jobs ] + return ApiServerEnvelope(data=data) @router.get("/{task_id}", response_model=TaskStatus, name="get_async_job_status") @@ -72,7 +73,7 @@ async def get_async_job_status( job_id=task_id, job_id_data=_get_job_id_data(user_id, product_name), ) - _task_id = str(async_job_rpc_status.job_id) + _task_id = f"{async_job_rpc_status.job_id}" return TaskStatus( task_progress=TaskProgress( task_id=_task_id, percent=async_job_rpc_status.progress.percent_value diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/tasks.py b/services/api-server/src/simcore_service_api_server/models/schemas/tasks.py new file mode 100644 index 000000000000..06dddc9583f7 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/models/schemas/tasks.py @@ -0,0 +1,9 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel + +DataT = TypeVar("DataT") + + +class ApiServerEnvelope(BaseModel, Generic[DataT]): + data: DataT From 10fbffd2329d53859926facacfbed6c2f1af3118 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 14:47:05 +0200 Subject: [PATCH 04/30] start adding unit tests --- .../helpers/async_jobs_server.py | 71 +++++++++++++++++++ services/api-server/tests/unit/test_tasks.py | 55 ++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py create mode 100644 services/api-server/tests/unit/test_tasks.py diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py b/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py new file mode 100644 index 000000000000..491540faf272 --- /dev/null +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py @@ -0,0 +1,71 @@ +from models_library.api_schemas_rpc_async_jobs.async_jobs import ( + AsyncJobId, + AsyncJobNameData, + AsyncJobResult, + AsyncJobStatus, +) +from models_library.progress_bar import ProgressReport +from models_library.rabbitmq_basic_types import RPCNamespace +from pydantic import validate_call +from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient + + +class AsyncJobSideEffects: + + @validate_call(config={"arbitrary_types_allowed": True}) + async def cancel( + self, + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + rpc_namespace: RPCNamespace, + job_id: AsyncJobId, + job_id_data: AsyncJobNameData, + ) -> None: + pass + + @validate_call(config={"arbitrary_types_allowed": True}) + async def status( + self, + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + rpc_namespace: RPCNamespace, + job_id: AsyncJobId, + job_id_data: AsyncJobNameData, + ) -> AsyncJobStatus: + return AsyncJobStatus( + job_id=job_id, + progress=ProgressReport( + actual_value=50.0, + total=100.0, + attempt=1, + ), + done=False, + ) + + @validate_call(config={"arbitrary_types_allowed": True}) + async def result( + self, + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + rpc_namespace: RPCNamespace, + job_id: AsyncJobId, + job_id_data: AsyncJobNameData, + ) -> AsyncJobResult: + return AsyncJobResult(result="Success") + + @validate_call(config={"arbitrary_types_allowed": True}) + async def list_jobs( + self, + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + rpc_namespace: RPCNamespace, + job_id_data: AsyncJobNameData, + filter_: str = "", + ) -> list[AsyncJobStatus]: + return [ + AsyncJobStatus( + job_id=AsyncJobId("123e4567-e89b-12d3-a456-426614174000"), + progress=ProgressReport(actual_value=50.0, total=100.0, attempt=1), + done=False, + ) + ] diff --git a/services/api-server/tests/unit/test_tasks.py b/services/api-server/tests/unit/test_tasks.py new file mode 100644 index 000000000000..67ad5297d418 --- /dev/null +++ b/services/api-server/tests/unit/test_tasks.py @@ -0,0 +1,55 @@ +from typing import Any + +import pytest +from httpx import AsyncClient +from models_library.api_schemas_long_running_tasks.tasks import TaskGet +from pytest_mock import MockerFixture, MockType +from pytest_simcore.helpers.async_jobs_server import AsyncJobSideEffects +from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope + + +@pytest.fixture +async def async_jobs_rpc_side_effects() -> Any: + return AsyncJobSideEffects() + + +@pytest.fixture +def mocked_async_jobs_rpc_api( + mocker: MockerFixture, async_jobs_rpc_side_effects: Any +) -> dict[str, MockType]: + """ + Mocks the catalog's simcore service RPC API for testing purposes. + """ + from servicelib.rabbitmq.rpc_interfaces.async_jobs import async_jobs + + mocks = {} + + # Get all callable methods from the side effects class that are not built-ins + side_effect_methods = [ + method_name + for method_name in dir(async_jobs_rpc_side_effects) + if not method_name.startswith("_") + and callable(getattr(async_jobs_rpc_side_effects, method_name)) + ] + + # Create mocks for each method in catalog_rpc that has a corresponding side effect + for method_name in side_effect_methods: + assert hasattr(async_jobs, method_name) + mocks[method_name] = mocker.patch.object( + async_jobs, + method_name, + autospec=True, + side_effect=getattr(async_jobs_rpc_side_effects, method_name), + ) + + return mocks + + +async def test_get_async_jobs( + client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType] +): + + response = await client.get("/v0/tasks") + assert response.status_code == 200 + ApiServerEnvelope[list[TaskGet]].model_validate_json(response.json()) + assert mocked_async_jobs_rpc_api["list_jobs"].called From 8d3b509276803d127176d614d330963ed5b18b21 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 15:01:09 +0200 Subject: [PATCH 05/30] first test --- .../pytest_simcore/helpers/async_jobs_server.py | 15 ++++++++------- services/api-server/tests/unit/test_tasks.py | 15 +++++++++------ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py b/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py index 491540faf272..67e967e1aa38 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py @@ -1,4 +1,5 @@ from models_library.api_schemas_rpc_async_jobs.async_jobs import ( + AsyncJobGet, AsyncJobId, AsyncJobNameData, AsyncJobResult, @@ -7,6 +8,7 @@ from models_library.progress_bar import ProgressReport from models_library.rabbitmq_basic_types import RPCNamespace from pydantic import validate_call +from pytest_mock import MockType from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient @@ -26,7 +28,7 @@ async def cancel( @validate_call(config={"arbitrary_types_allowed": True}) async def status( self, - rabbitmq_rpc_client: RabbitMQRPCClient, + rabbitmq_rpc_client: RabbitMQRPCClient | MockType, *, rpc_namespace: RPCNamespace, job_id: AsyncJobId, @@ -45,7 +47,7 @@ async def status( @validate_call(config={"arbitrary_types_allowed": True}) async def result( self, - rabbitmq_rpc_client: RabbitMQRPCClient, + rabbitmq_rpc_client: RabbitMQRPCClient | MockType, *, rpc_namespace: RPCNamespace, job_id: AsyncJobId, @@ -56,16 +58,15 @@ async def result( @validate_call(config={"arbitrary_types_allowed": True}) async def list_jobs( self, - rabbitmq_rpc_client: RabbitMQRPCClient, + rabbitmq_rpc_client: RabbitMQRPCClient | MockType, *, rpc_namespace: RPCNamespace, job_id_data: AsyncJobNameData, filter_: str = "", - ) -> list[AsyncJobStatus]: + ) -> list[AsyncJobGet]: return [ - AsyncJobStatus( + AsyncJobGet( job_id=AsyncJobId("123e4567-e89b-12d3-a456-426614174000"), - progress=ProgressReport(actual_value=50.0, total=100.0, attempt=1), - done=False, + job_name="Example Job", ) ] diff --git a/services/api-server/tests/unit/test_tasks.py b/services/api-server/tests/unit/test_tasks.py index 67ad5297d418..2c08da47fa09 100644 --- a/services/api-server/tests/unit/test_tasks.py +++ b/services/api-server/tests/unit/test_tasks.py @@ -1,7 +1,8 @@ from typing import Any import pytest -from httpx import AsyncClient +from fastapi import status +from httpx import AsyncClient, BasicAuth from models_library.api_schemas_long_running_tasks.tasks import TaskGet from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.async_jobs_server import AsyncJobSideEffects @@ -15,7 +16,9 @@ async def async_jobs_rpc_side_effects() -> Any: @pytest.fixture def mocked_async_jobs_rpc_api( - mocker: MockerFixture, async_jobs_rpc_side_effects: Any + mocker: MockerFixture, + async_jobs_rpc_side_effects: Any, + mocked_app_dependencies: None, ) -> dict[str, MockType]: """ Mocks the catalog's simcore service RPC API for testing purposes. @@ -46,10 +49,10 @@ def mocked_async_jobs_rpc_api( async def test_get_async_jobs( - client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType] + client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType], auth: BasicAuth ): - response = await client.get("/v0/tasks") - assert response.status_code == 200 - ApiServerEnvelope[list[TaskGet]].model_validate_json(response.json()) + response = await client.get("/v0/tasks", auth=auth) + assert response.status_code == status.HTTP_200_OK + ApiServerEnvelope[list[TaskGet]].model_validate_json(response.text) assert mocked_async_jobs_rpc_api["list_jobs"].called From b397f97d55d2abfaca1d765f9363d48ede330a9a Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 15:19:16 +0200 Subject: [PATCH 06/30] ensure operations match google api guideline --- .../api/routes/tasks.py | 15 ++++++++---- services/api-server/tests/unit/test_tasks.py | 23 +++++++++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index f4dcab27b353..85ff6433ab06 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -1,7 +1,7 @@ import logging from typing import Annotated -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, FastAPI, status from models_library.api_schemas_long_running_tasks.base import TaskProgress from models_library.api_schemas_long_running_tasks.tasks import ( TaskGet, @@ -15,6 +15,7 @@ from models_library.api_schemas_storage import STORAGE_RPC_NAMESPACE from models_library.products import ProductName from models_library.users import UserID +from servicelib.fastapi.dependencies import get_app from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient from servicelib.rabbitmq.rpc_interfaces.async_jobs import async_jobs from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope @@ -33,6 +34,7 @@ def _get_job_id_data(user_id: UserID, product_name: ProductName) -> AsyncJobName @router.get("", response_model=ApiServerEnvelope[list[TaskGet]]) async def get_async_jobs( + app: Annotated[FastAPI, Depends(get_app)], user_id: Annotated[UserID, Depends(get_current_user_id)], product_name: Annotated[ProductName, Depends(get_product_name)], rabbitmq_rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], @@ -43,15 +45,18 @@ async def get_async_jobs( job_id_data=_get_job_id_data(user_id, product_name), filter_="", ) + app_router = app.router data = [ TaskGet( task_id=f"{job.job_id}", task_name=job.job_name, - status_href=router.url_path_for( + status_href=app_router.url_path_for( "get_async_job_status", task_id=f"{job.job_id}" ), - abort_href=router.url_path_for("cancel_async_job", task_id=f"{job.job_id}"), - result_href=router.url_path_for( + abort_href=app_router.url_path_for( + "cancel_async_job", task_id=f"{job.job_id}" + ), + result_href=app_router.url_path_for( "get_async_job_result", task_id=f"{job.job_id}" ), ) @@ -84,7 +89,7 @@ async def get_async_job_status( @router.delete( - "/{task_id}/cancel", status_code=status.HTTP_204_NO_CONTENT, name="cancel_async_job" + "/{task_id}:cancel", status_code=status.HTTP_204_NO_CONTENT, name="cancel_async_job" ) async def cancel_async_job( task_id: AsyncJobId, diff --git a/services/api-server/tests/unit/test_tasks.py b/services/api-server/tests/unit/test_tasks.py index 2c08da47fa09..525e684c45b3 100644 --- a/services/api-server/tests/unit/test_tasks.py +++ b/services/api-server/tests/unit/test_tasks.py @@ -3,7 +3,7 @@ import pytest from fastapi import status from httpx import AsyncClient, BasicAuth -from models_library.api_schemas_long_running_tasks.tasks import TaskGet +from models_library.api_schemas_long_running_tasks.tasks import TaskGet, TaskStatus from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.async_jobs_server import AsyncJobSideEffects from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope @@ -54,5 +54,24 @@ async def test_get_async_jobs( response = await client.get("/v0/tasks", auth=auth) assert response.status_code == status.HTTP_200_OK - ApiServerEnvelope[list[TaskGet]].model_validate_json(response.text) assert mocked_async_jobs_rpc_api["list_jobs"].called + result = ApiServerEnvelope[list[TaskGet]].model_validate_json(response.text) + assert len(result.data) > 0 + assert all(isinstance(task, TaskGet) for task in result.data) + task = result.data[0] + assert task.abort_href == f"/v0/tasks/{task.task_id}:cancel" + assert task.result_href == f"/v0/tasks/{task.task_id}/result" + assert task.status_href == f"/v0/tasks/{task.task_id}" + + +async def test_get_async_jobs_status( + client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType], auth: BasicAuth +): + task_id = "123e4567-e89b-12d3-a456-426614174000" + response = await client.get( + f"/v0/tasks/{task_id}/status", auth=auth, follow_redirects=True + ) + assert response.status_code == status.HTTP_200_OK + assert mocked_async_jobs_rpc_api["status"].called + assert mocked_async_jobs_rpc_api["status"].call_args[1]["job_id"] == task_id + TaskStatus.model_validate_json(response.text) From fa701a9e5bcd7260db3119fee07d837fbef44638 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 15:21:27 +0200 Subject: [PATCH 07/30] fix unit test of get status endpoint --- services/api-server/tests/unit/test_tasks.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/services/api-server/tests/unit/test_tasks.py b/services/api-server/tests/unit/test_tasks.py index 525e684c45b3..2abc1713c128 100644 --- a/services/api-server/tests/unit/test_tasks.py +++ b/services/api-server/tests/unit/test_tasks.py @@ -68,10 +68,8 @@ async def test_get_async_jobs_status( client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType], auth: BasicAuth ): task_id = "123e4567-e89b-12d3-a456-426614174000" - response = await client.get( - f"/v0/tasks/{task_id}/status", auth=auth, follow_redirects=True - ) + response = await client.get(f"/v0/tasks/{task_id}", auth=auth) assert response.status_code == status.HTTP_200_OK assert mocked_async_jobs_rpc_api["status"].called - assert mocked_async_jobs_rpc_api["status"].call_args[1]["job_id"] == task_id + assert f"{mocked_async_jobs_rpc_api['status'].call_args[1]['job_id']}" == task_id TaskStatus.model_validate_json(response.text) From ec6e4084720a024cd4ff0c1b2b6f1c5a62d96cf8 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 15:22:29 +0200 Subject: [PATCH 08/30] cleanup --- services/api-server/tests/unit/test_tasks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/api-server/tests/unit/test_tasks.py b/services/api-server/tests/unit/test_tasks.py index 2abc1713c128..96b269b60614 100644 --- a/services/api-server/tests/unit/test_tasks.py +++ b/services/api-server/tests/unit/test_tasks.py @@ -1,6 +1,7 @@ from typing import Any import pytest +from faker import Faker from fastapi import status from httpx import AsyncClient, BasicAuth from models_library.api_schemas_long_running_tasks.tasks import TaskGet, TaskStatus @@ -8,6 +9,8 @@ from pytest_simcore.helpers.async_jobs_server import AsyncJobSideEffects from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope +_faker = Faker() + @pytest.fixture async def async_jobs_rpc_side_effects() -> Any: @@ -67,7 +70,7 @@ async def test_get_async_jobs( async def test_get_async_jobs_status( client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType], auth: BasicAuth ): - task_id = "123e4567-e89b-12d3-a456-426614174000" + task_id = f"{_faker.uuid4()}" response = await client.get(f"/v0/tasks/{task_id}", auth=auth) assert response.status_code == status.HTTP_200_OK assert mocked_async_jobs_rpc_api["status"].called From 5cd308557c096985583ee85c0dc4b85a1ab066e4 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 15:27:23 +0200 Subject: [PATCH 09/30] add unit tests for additional endpoints --- .../helpers/async_jobs_server.py | 2 +- .../api/routes/tasks.py | 2 +- services/api-server/tests/unit/test_tasks.py | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py b/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py index 67e967e1aa38..f26901dc023d 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py @@ -17,7 +17,7 @@ class AsyncJobSideEffects: @validate_call(config={"arbitrary_types_allowed": True}) async def cancel( self, - rabbitmq_rpc_client: RabbitMQRPCClient, + rabbitmq_rpc_client: RabbitMQRPCClient | MockType, *, rpc_namespace: RPCNamespace, job_id: AsyncJobId, diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index 85ff6433ab06..e8df67532ea1 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -88,7 +88,7 @@ async def get_async_job_status( ) -@router.delete( +@router.post( "/{task_id}:cancel", status_code=status.HTTP_204_NO_CONTENT, name="cancel_async_job" ) async def cancel_async_job( diff --git a/services/api-server/tests/unit/test_tasks.py b/services/api-server/tests/unit/test_tasks.py index 96b269b60614..f4e4e843ed95 100644 --- a/services/api-server/tests/unit/test_tasks.py +++ b/services/api-server/tests/unit/test_tasks.py @@ -76,3 +76,23 @@ async def test_get_async_jobs_status( assert mocked_async_jobs_rpc_api["status"].called assert f"{mocked_async_jobs_rpc_api['status'].call_args[1]['job_id']}" == task_id TaskStatus.model_validate_json(response.text) + + +async def test_cancel_async_job( + client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType], auth: BasicAuth +): + task_id = f"{_faker.uuid4()}" + response = await client.post(f"/v0/tasks/{task_id}:cancel", auth=auth) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert mocked_async_jobs_rpc_api["cancel"].called + assert f"{mocked_async_jobs_rpc_api['cancel'].call_args[1]['job_id']}" == task_id + + +async def test_get_async_job_result( + client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType], auth: BasicAuth +): + task_id = f"{_faker.uuid4()}" + response = await client.get(f"/v0/tasks/{task_id}/result", auth=auth) + assert response.status_code == status.HTTP_200_OK + assert mocked_async_jobs_rpc_api["result"].called + assert f"{mocked_async_jobs_rpc_api['result'].call_args[1]['job_id']}" == task_id From f93631db91e3afc2233ec13455ea0d71eadcc19b Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 15:46:00 +0200 Subject: [PATCH 10/30] inital implementation of rpc client to convert exceptions --- .../simcore_service_api_server/services_rpc/async_jobs.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py b/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py new file mode 100644 index 000000000000..42c42ad77b68 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py @@ -0,0 +1,7 @@ +from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient + + +class AsyncJobClient: + + def __init__(self, rabbitmq_rpc_client: RabbitMQRPCClient): + self._rabbitmq_rpc_client = rabbitmq_rpc_client From b18792d200f4f6feff18916e1086dd456122762d Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Wed, 2 Jul 2025 22:58:15 +0200 Subject: [PATCH 11/30] further improvements --- .../api_schemas_rpc_async_jobs/exceptions.py | 2 +- .../api/dependencies/tasks.py | 13 +++ .../api/routes/tasks.py | 22 ++--- .../handlers/_handlers_backend_errors.py | 22 ++++- .../exceptions/task_errors.py | 37 ++++++++ .../services_rpc/async_jobs.py | 90 ++++++++++++++++++- 6 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 services/api-server/src/simcore_service_api_server/api/dependencies/tasks.py create mode 100644 services/api-server/src/simcore_service_api_server/exceptions/task_errors.py diff --git a/packages/models-library/src/models_library/api_schemas_rpc_async_jobs/exceptions.py b/packages/models-library/src/models_library/api_schemas_rpc_async_jobs/exceptions.py index 7399b6ff303d..67c0dd5353c6 100644 --- a/packages/models-library/src/models_library/api_schemas_rpc_async_jobs/exceptions.py +++ b/packages/models-library/src/models_library/api_schemas_rpc_async_jobs/exceptions.py @@ -6,7 +6,7 @@ class BaseAsyncjobRpcError(OsparcErrorMixin, RuntimeError): class JobSchedulerError(BaseAsyncjobRpcError): - msg_template: str = "Celery exception: {exc}" + msg_template: str = "Async job scheduler exception: {exc}" class JobMissingError(BaseAsyncjobRpcError): diff --git a/services/api-server/src/simcore_service_api_server/api/dependencies/tasks.py b/services/api-server/src/simcore_service_api_server/api/dependencies/tasks.py new file mode 100644 index 000000000000..645aba6a8ff4 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/api/dependencies/tasks.py @@ -0,0 +1,13 @@ +from typing import Annotated + +from fastapi import Depends +from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient + +from ...services_rpc.async_jobs import AsyncJobClient +from .rabbitmq import get_rabbitmq_rpc_client + + +def get_async_jobs_client( + rabbitmq_rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], +) -> AsyncJobClient: + return AsyncJobClient(_rabbitmq_rpc_client=rabbitmq_rpc_client) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index e8df67532ea1..b47dd5a957af 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -12,17 +12,15 @@ AsyncJobId, AsyncJobNameData, ) -from models_library.api_schemas_storage import STORAGE_RPC_NAMESPACE from models_library.products import ProductName from models_library.users import UserID from servicelib.fastapi.dependencies import get_app -from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient -from servicelib.rabbitmq.rpc_interfaces.async_jobs import async_jobs from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope +from ...services_rpc.async_jobs import AsyncJobClient from ..dependencies.authentication import get_current_user_id -from ..dependencies.rabbitmq import get_rabbitmq_rpc_client from ..dependencies.services import get_product_name +from ..dependencies.tasks import get_async_jobs_client router = APIRouter() _logger = logging.getLogger(__name__) @@ -37,11 +35,9 @@ async def get_async_jobs( app: Annotated[FastAPI, Depends(get_app)], user_id: Annotated[UserID, Depends(get_current_user_id)], product_name: Annotated[ProductName, Depends(get_product_name)], - rabbitmq_rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], + async_jobs: Annotated[AsyncJobClient, Depends(get_async_jobs_client)], ): user_async_jobs = await async_jobs.list_jobs( - rabbitmq_rpc_client=rabbitmq_rpc_client, - rpc_namespace=STORAGE_RPC_NAMESPACE, job_id_data=_get_job_id_data(user_id, product_name), filter_="", ) @@ -70,11 +66,9 @@ async def get_async_job_status( task_id: AsyncJobId, user_id: Annotated[UserID, Depends(get_current_user_id)], product_name: Annotated[ProductName, Depends(get_product_name)], - rabbitmq_rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], + async_jobs: Annotated[AsyncJobClient, Depends(get_async_jobs_client)], ): async_job_rpc_status = await async_jobs.status( - rabbitmq_rpc_client=rabbitmq_rpc_client, - rpc_namespace=STORAGE_RPC_NAMESPACE, job_id=task_id, job_id_data=_get_job_id_data(user_id, product_name), ) @@ -95,11 +89,9 @@ async def cancel_async_job( task_id: AsyncJobId, user_id: Annotated[UserID, Depends(get_current_user_id)], product_name: Annotated[ProductName, Depends(get_product_name)], - rabbitmq_rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], + async_jobs: Annotated[AsyncJobClient, Depends(get_async_jobs_client)], ): await async_jobs.cancel( - rabbitmq_rpc_client=rabbitmq_rpc_client, - rpc_namespace=STORAGE_RPC_NAMESPACE, job_id=task_id, job_id_data=_get_job_id_data(user_id, product_name), ) @@ -110,11 +102,9 @@ async def get_async_job_result( task_id: AsyncJobId, user_id: Annotated[UserID, Depends(get_current_user_id)], product_name: Annotated[ProductName, Depends(get_product_name)], - rabbitmq_rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], + async_jobs: Annotated[AsyncJobClient, Depends(get_async_jobs_client)], ): async_job_rpc_result = await async_jobs.result( - rabbitmq_rpc_client=rabbitmq_rpc_client, - rpc_namespace=STORAGE_RPC_NAMESPACE, job_id=task_id, job_id_data=_get_job_id_data(user_id, product_name), ) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py index ca335deceacc..3ae63fd2894a 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py @@ -1,12 +1,30 @@ +import logging + +from common_library.error_codes import create_error_code +from fastapi import status +from servicelib.logging_errors import create_troubleshootting_log_kwargs from starlette.requests import Request from starlette.responses import JSONResponse from ...exceptions.backend_errors import BaseBackEndError from ._utils import create_error_json_response +_logger = logging.getLogger(__name__) + async def backend_error_handler(request: Request, exc: Exception) -> JSONResponse: assert request # nosec assert isinstance(exc, BaseBackEndError) - - return create_error_json_response(f"{exc}", status_code=exc.status_code) + user_error_msg = f"{exc}" + if not exc.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR: + oec = create_error_code(exc) + user_error_msg += f" [{oec}]" + _logger.exception( + **create_troubleshootting_log_kwargs( + user_error_msg, + error=exc, + error_code=oec, + tip="Unexpected error", + ) + ) + return create_error_json_response(user_error_msg, status_code=exc.status_code) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py new file mode 100644 index 000000000000..91e7f7a0479a --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py @@ -0,0 +1,37 @@ +from fastapi import status + +from .backend_errors import BaseBackEndError + + +class TaskBaseError(BaseBackEndError): + pass + + +class TaskSchedulerError(TaskBaseError): + msg_template: str = "TaskScheduler error" + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + + +class TaskMissingError(TaskBaseError): + msg_template: str = "Task {job_id} does not exist" + status_code = status.HTTP_404_NOT_FOUND + + +class TaskStatusError(TaskBaseError): + msg_template: str = "Could not get status of task {job_id}" + status_code = status.HTTP_404_NOT_FOUND + + +class TaskNotDoneError(TaskBaseError): + msg_template: str = "Task {job_id} not done" + status_code = status.HTTP_409_CONFLICT + + +class TaskCancelledError(TaskBaseError): + msg_template: str = "Task {job_id} cancelled" + status_code = status.HTTP_409_CONFLICT + + +class TaskError(TaskBaseError): + msg_template: str = "Task '{job_id}' failed" + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py b/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py index 42c42ad77b68..f99bb46851ba 100644 --- a/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py +++ b/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py @@ -1,7 +1,93 @@ +import functools +from dataclasses import dataclass + +from models_library.api_schemas_rpc_async_jobs.async_jobs import ( + AsyncJobGet, + AsyncJobId, + AsyncJobNameData, + AsyncJobResult, + AsyncJobStatus, +) +from models_library.api_schemas_rpc_async_jobs.exceptions import ( + JobAbortedError, + JobError, + JobMissingError, + JobNotDoneError, + JobSchedulerError, + JobStatusError, +) +from models_library.api_schemas_storage import STORAGE_RPC_NAMESPACE +from servicelib.long_running_tasks.errors import TaskCancelledError from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.async_jobs import async_jobs +from simcore_service_api_server.exceptions.task_errors import ( + TaskError, + TaskMissingError, + TaskNotDoneError, + TaskSchedulerError, + TaskStatusError, +) + +from ..exceptions.service_errors_utils import service_exception_mapper + +_exception_mapper = functools.partial( + service_exception_mapper, service_name="Async jobs" +) + +_exception_map = { + JobSchedulerError: TaskSchedulerError, + JobMissingError: TaskMissingError, + JobStatusError: TaskStatusError, + JobNotDoneError: TaskNotDoneError, + JobAbortedError: TaskCancelledError, + JobError: TaskError, +} +@dataclass class AsyncJobClient: + _rabbitmq_rpc_client: RabbitMQRPCClient + + @_exception_mapper(rpc_exception_map=_exception_map) + async def cancel( + self, *, job_id: AsyncJobId, job_id_data: AsyncJobNameData + ) -> None: + return await async_jobs.cancel( + self._rabbitmq_rpc_client, + rpc_namespace=STORAGE_RPC_NAMESPACE, + job_id=job_id, + job_id_data=job_id_data, + ) + + @_exception_mapper(rpc_exception_map=_exception_map) + async def status( + self, *, job_id: AsyncJobId, job_id_data: AsyncJobNameData + ) -> AsyncJobStatus: + return await async_jobs.status( + self._rabbitmq_rpc_client, + rpc_namespace=STORAGE_RPC_NAMESPACE, + job_id=job_id, + job_id_data=job_id_data, + ) + + @_exception_mapper(rpc_exception_map=_exception_map) + async def result( + self, *, job_id: AsyncJobId, job_id_data: AsyncJobNameData + ) -> AsyncJobResult: + return await async_jobs.result( + self._rabbitmq_rpc_client, + rpc_namespace=STORAGE_RPC_NAMESPACE, + job_id=job_id, + job_id_data=job_id_data, + ) - def __init__(self, rabbitmq_rpc_client: RabbitMQRPCClient): - self._rabbitmq_rpc_client = rabbitmq_rpc_client + @_exception_mapper(rpc_exception_map=_exception_map) + async def list_jobs( + self, *, filter_: str, job_id_data: AsyncJobNameData + ) -> list[AsyncJobGet]: + return await async_jobs.list_jobs( + self._rabbitmq_rpc_client, + rpc_namespace=STORAGE_RPC_NAMESPACE, + filter_=filter_, + job_id_data=job_id_data, + ) From bc7ce89ee14e0d17deadfa427b019b33a807b826 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Thu, 3 Jul 2025 07:03:07 +0200 Subject: [PATCH 12/30] clean up error mapping --- .../exceptions/task_errors.py | 16 +++---- .../services_rpc/async_jobs.py | 42 +++++++++++-------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py index 91e7f7a0479a..38c439666877 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py @@ -3,35 +3,31 @@ from .backend_errors import BaseBackEndError -class TaskBaseError(BaseBackEndError): - pass - - -class TaskSchedulerError(TaskBaseError): +class TaskSchedulerError(BaseBackEndError): msg_template: str = "TaskScheduler error" status_code = status.HTTP_500_INTERNAL_SERVER_ERROR -class TaskMissingError(TaskBaseError): +class TaskMissingError(BaseBackEndError): msg_template: str = "Task {job_id} does not exist" status_code = status.HTTP_404_NOT_FOUND -class TaskStatusError(TaskBaseError): +class TaskStatusError(BaseBackEndError): msg_template: str = "Could not get status of task {job_id}" status_code = status.HTTP_404_NOT_FOUND -class TaskNotDoneError(TaskBaseError): +class TaskNotDoneError(BaseBackEndError): msg_template: str = "Task {job_id} not done" status_code = status.HTTP_409_CONFLICT -class TaskCancelledError(TaskBaseError): +class TaskCancelledError(BaseBackEndError): msg_template: str = "Task {job_id} cancelled" status_code = status.HTTP_409_CONFLICT -class TaskError(TaskBaseError): +class TaskError(BaseBackEndError): msg_template: str = "Task '{job_id}' failed" status_code = status.HTTP_500_INTERNAL_SERVER_ERROR diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py b/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py index f99bb46851ba..f1cc0e03fbb4 100644 --- a/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py +++ b/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py @@ -11,21 +11,17 @@ from models_library.api_schemas_rpc_async_jobs.exceptions import ( JobAbortedError, JobError, - JobMissingError, JobNotDoneError, JobSchedulerError, - JobStatusError, ) from models_library.api_schemas_storage import STORAGE_RPC_NAMESPACE -from servicelib.long_running_tasks.errors import TaskCancelledError from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient from servicelib.rabbitmq.rpc_interfaces.async_jobs import async_jobs from simcore_service_api_server.exceptions.task_errors import ( + TaskCancelledError, TaskError, - TaskMissingError, TaskNotDoneError, TaskSchedulerError, - TaskStatusError, ) from ..exceptions.service_errors_utils import service_exception_mapper @@ -34,21 +30,16 @@ service_exception_mapper, service_name="Async jobs" ) -_exception_map = { - JobSchedulerError: TaskSchedulerError, - JobMissingError: TaskMissingError, - JobStatusError: TaskStatusError, - JobNotDoneError: TaskNotDoneError, - JobAbortedError: TaskCancelledError, - JobError: TaskError, -} - @dataclass class AsyncJobClient: _rabbitmq_rpc_client: RabbitMQRPCClient - @_exception_mapper(rpc_exception_map=_exception_map) + @_exception_mapper( + rpc_exception_map={ + JobSchedulerError: TaskSchedulerError, + } + ) async def cancel( self, *, job_id: AsyncJobId, job_id_data: AsyncJobNameData ) -> None: @@ -59,7 +50,11 @@ async def cancel( job_id_data=job_id_data, ) - @_exception_mapper(rpc_exception_map=_exception_map) + @_exception_mapper( + rpc_exception_map={ + JobSchedulerError: TaskSchedulerError, + } + ) async def status( self, *, job_id: AsyncJobId, job_id_data: AsyncJobNameData ) -> AsyncJobStatus: @@ -70,7 +65,14 @@ async def status( job_id_data=job_id_data, ) - @_exception_mapper(rpc_exception_map=_exception_map) + @_exception_mapper( + rpc_exception_map={ + JobSchedulerError: TaskSchedulerError, + JobNotDoneError: TaskNotDoneError, + JobAbortedError: TaskCancelledError, + JobError: TaskError, + } + ) async def result( self, *, job_id: AsyncJobId, job_id_data: AsyncJobNameData ) -> AsyncJobResult: @@ -81,7 +83,11 @@ async def result( job_id_data=job_id_data, ) - @_exception_mapper(rpc_exception_map=_exception_map) + @_exception_mapper( + rpc_exception_map={ + JobSchedulerError: TaskSchedulerError, + } + ) async def list_jobs( self, *, filter_: str, job_id_data: AsyncJobNameData ) -> list[AsyncJobGet]: From 9542bc2f3a199d3acebd5cab91bf6b7461295d2f Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Thu, 3 Jul 2025 07:39:58 +0200 Subject: [PATCH 13/30] cover exceptions in tests --- .../helpers/async_jobs_server.py | 16 ++- .../exceptions/task_errors.py | 11 +- .../services_rpc/async_jobs.py | 4 +- services/api-server/tests/unit/test_tasks.py | 136 ++++++++++++++---- 4 files changed, 132 insertions(+), 35 deletions(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py b/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py index f26901dc023d..70cfd3f4c171 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + from models_library.api_schemas_rpc_async_jobs.async_jobs import ( AsyncJobGet, AsyncJobId, @@ -5,6 +7,7 @@ AsyncJobResult, AsyncJobStatus, ) +from models_library.api_schemas_rpc_async_jobs.exceptions import BaseAsyncjobRpcError from models_library.progress_bar import ProgressReport from models_library.rabbitmq_basic_types import RPCNamespace from pydantic import validate_call @@ -12,7 +15,9 @@ from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient +@dataclass class AsyncJobSideEffects: + exception: BaseAsyncjobRpcError | None = None @validate_call(config={"arbitrary_types_allowed": True}) async def cancel( @@ -23,7 +28,9 @@ async def cancel( job_id: AsyncJobId, job_id_data: AsyncJobNameData, ) -> None: - pass + if self.exception is not None: + raise self.exception + return None @validate_call(config={"arbitrary_types_allowed": True}) async def status( @@ -34,6 +41,9 @@ async def status( job_id: AsyncJobId, job_id_data: AsyncJobNameData, ) -> AsyncJobStatus: + if self.exception is not None: + raise self.exception + return AsyncJobStatus( job_id=job_id, progress=ProgressReport( @@ -53,6 +63,8 @@ async def result( job_id: AsyncJobId, job_id_data: AsyncJobNameData, ) -> AsyncJobResult: + if self.exception is not None: + raise self.exception return AsyncJobResult(result="Success") @validate_call(config={"arbitrary_types_allowed": True}) @@ -64,6 +76,8 @@ async def list_jobs( job_id_data: AsyncJobNameData, filter_: str = "", ) -> list[AsyncJobGet]: + if self.exception is not None: + raise self.exception return [ AsyncJobGet( job_id=AsyncJobId("123e4567-e89b-12d3-a456-426614174000"), diff --git a/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py index 38c439666877..a12282404823 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/task_errors.py @@ -13,18 +13,13 @@ class TaskMissingError(BaseBackEndError): status_code = status.HTTP_404_NOT_FOUND -class TaskStatusError(BaseBackEndError): - msg_template: str = "Could not get status of task {job_id}" +class TaskResultMissingError(BaseBackEndError): + msg_template: str = "Task {job_id} is not done" status_code = status.HTTP_404_NOT_FOUND -class TaskNotDoneError(BaseBackEndError): - msg_template: str = "Task {job_id} not done" - status_code = status.HTTP_409_CONFLICT - - class TaskCancelledError(BaseBackEndError): - msg_template: str = "Task {job_id} cancelled" + msg_template: str = "Task {job_id} is cancelled" status_code = status.HTTP_409_CONFLICT diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py b/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py index f1cc0e03fbb4..fd687967a9e4 100644 --- a/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py +++ b/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py @@ -20,7 +20,7 @@ from simcore_service_api_server.exceptions.task_errors import ( TaskCancelledError, TaskError, - TaskNotDoneError, + TaskResultMissingError, TaskSchedulerError, ) @@ -68,7 +68,7 @@ async def status( @_exception_mapper( rpc_exception_map={ JobSchedulerError: TaskSchedulerError, - JobNotDoneError: TaskNotDoneError, + JobNotDoneError: TaskResultMissingError, JobAbortedError: TaskCancelledError, JobError: TaskError, } diff --git a/services/api-server/tests/unit/test_tasks.py b/services/api-server/tests/unit/test_tasks.py index f4e4e843ed95..0ff3e5e15821 100644 --- a/services/api-server/tests/unit/test_tasks.py +++ b/services/api-server/tests/unit/test_tasks.py @@ -5,6 +5,13 @@ from fastapi import status from httpx import AsyncClient, BasicAuth from models_library.api_schemas_long_running_tasks.tasks import TaskGet, TaskStatus +from models_library.api_schemas_rpc_async_jobs.exceptions import ( + BaseAsyncjobRpcError, + JobAbortedError, + JobError, + JobNotDoneError, + JobSchedulerError, +) from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.async_jobs_server import AsyncJobSideEffects from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope @@ -13,8 +20,10 @@ @pytest.fixture -async def async_jobs_rpc_side_effects() -> Any: - return AsyncJobSideEffects() +async def async_jobs_rpc_side_effects( + async_job_error: BaseAsyncjobRpcError | None, +) -> Any: + return AsyncJobSideEffects(exception=async_job_error) @pytest.fixture @@ -51,48 +60,127 @@ def mocked_async_jobs_rpc_api( return mocks +@pytest.mark.parametrize( + "async_job_error, expected_status_code", + [ + (None, status.HTTP_200_OK), + ( + JobSchedulerError( + exc=Exception("A very rare exception raised by the scheduler") + ), + status.HTTP_500_INTERNAL_SERVER_ERROR, + ), + ], +) async def test_get_async_jobs( - client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType], auth: BasicAuth + client: AsyncClient, + mocked_async_jobs_rpc_api: dict[str, MockType], + auth: BasicAuth, + expected_status_code: int, ): response = await client.get("/v0/tasks", auth=auth) - assert response.status_code == status.HTTP_200_OK assert mocked_async_jobs_rpc_api["list_jobs"].called - result = ApiServerEnvelope[list[TaskGet]].model_validate_json(response.text) - assert len(result.data) > 0 - assert all(isinstance(task, TaskGet) for task in result.data) - task = result.data[0] - assert task.abort_href == f"/v0/tasks/{task.task_id}:cancel" - assert task.result_href == f"/v0/tasks/{task.task_id}/result" - assert task.status_href == f"/v0/tasks/{task.task_id}" - - + assert response.status_code == expected_status_code + + if response.status_code == status.HTTP_200_OK: + result = ApiServerEnvelope[list[TaskGet]].model_validate_json(response.text) + assert len(result.data) > 0 + assert all(isinstance(task, TaskGet) for task in result.data) + task = result.data[0] + assert task.abort_href == f"/v0/tasks/{task.task_id}:cancel" + assert task.result_href == f"/v0/tasks/{task.task_id}/result" + assert task.status_href == f"/v0/tasks/{task.task_id}" + + +@pytest.mark.parametrize( + "async_job_error, expected_status_code", + [ + (None, status.HTTP_200_OK), + ( + JobSchedulerError( + exc=Exception("A very rare exception raised by the scheduler") + ), + status.HTTP_500_INTERNAL_SERVER_ERROR, + ), + ], +) async def test_get_async_jobs_status( - client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType], auth: BasicAuth + client: AsyncClient, + mocked_async_jobs_rpc_api: dict[str, MockType], + auth: BasicAuth, + expected_status_code: int, ): task_id = f"{_faker.uuid4()}" response = await client.get(f"/v0/tasks/{task_id}", auth=auth) - assert response.status_code == status.HTTP_200_OK assert mocked_async_jobs_rpc_api["status"].called assert f"{mocked_async_jobs_rpc_api['status'].call_args[1]['job_id']}" == task_id - TaskStatus.model_validate_json(response.text) - - + assert response.status_code == expected_status_code + if response.status_code == status.HTTP_200_OK: + TaskStatus.model_validate_json(response.text) + + +@pytest.mark.parametrize( + "async_job_error, expected_status_code", + [ + (None, status.HTTP_204_NO_CONTENT), + ( + JobSchedulerError( + exc=Exception("A very rare exception raised by the scheduler") + ), + status.HTTP_500_INTERNAL_SERVER_ERROR, + ), + ], +) async def test_cancel_async_job( - client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType], auth: BasicAuth + client: AsyncClient, + mocked_async_jobs_rpc_api: dict[str, MockType], + auth: BasicAuth, + expected_status_code: int, ): task_id = f"{_faker.uuid4()}" response = await client.post(f"/v0/tasks/{task_id}:cancel", auth=auth) - assert response.status_code == status.HTTP_204_NO_CONTENT assert mocked_async_jobs_rpc_api["cancel"].called assert f"{mocked_async_jobs_rpc_api['cancel'].call_args[1]['job_id']}" == task_id - - + assert response.status_code == expected_status_code + + +@pytest.mark.parametrize( + "async_job_error, expected_status_code", + [ + (None, status.HTTP_200_OK), + ( + JobError( + job_id=_faker.uuid4(), + exc_type=Exception, + exc_message="An exception from inside the async job", + ), + status.HTTP_500_INTERNAL_SERVER_ERROR, + ), + ( + JobNotDoneError(job_id=_faker.uuid4()), + status.HTTP_404_NOT_FOUND, + ), + ( + JobAbortedError(job_id=_faker.uuid4()), + status.HTTP_409_CONFLICT, + ), + ( + JobSchedulerError( + exc=Exception("A very rare exception raised by the scheduler") + ), + status.HTTP_500_INTERNAL_SERVER_ERROR, + ), + ], +) async def test_get_async_job_result( - client: AsyncClient, mocked_async_jobs_rpc_api: dict[str, MockType], auth: BasicAuth + client: AsyncClient, + mocked_async_jobs_rpc_api: dict[str, MockType], + auth: BasicAuth, + expected_status_code: int, ): task_id = f"{_faker.uuid4()}" response = await client.get(f"/v0/tasks/{task_id}/result", auth=auth) - assert response.status_code == status.HTTP_200_OK + assert response.status_code == expected_status_code assert mocked_async_jobs_rpc_api["result"].called assert f"{mocked_async_jobs_rpc_api['result'].call_args[1]['job_id']}" == task_id From 2ae921bee107e10b93917c9873bd0d8bd47b7f0f Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Thu, 3 Jul 2025 09:36:56 +0200 Subject: [PATCH 14/30] add status codes to openapi specs --- .../api/routes/tasks.py | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index b47dd5a957af..6ed9e0867c8c 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -1,5 +1,5 @@ import logging -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends, FastAPI, status from models_library.api_schemas_long_running_tasks.base import TaskProgress @@ -13,6 +13,7 @@ AsyncJobNameData, ) from models_library.products import ProductName +from models_library.rest_error import ErrorGet from models_library.users import UserID from servicelib.fastapi.dependencies import get_app from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope @@ -30,7 +31,20 @@ def _get_job_id_data(user_id: UserID, product_name: ProductName) -> AsyncJobName return AsyncJobNameData(user_id=user_id, product_name=product_name) -@router.get("", response_model=ApiServerEnvelope[list[TaskGet]]) +_DEFAULT_TASK_STATUS_CODES: dict[int | str, dict[str, Any]] = { + status.HTTP_500_INTERNAL_SERVER_ERROR: { + "description": "Internal server error", + "model": ErrorGet, + }, +} + + +@router.get( + "", + response_model=ApiServerEnvelope[list[TaskGet]], + responses=_DEFAULT_TASK_STATUS_CODES, + status_code=status.HTTP_200_OK, +) async def get_async_jobs( app: Annotated[FastAPI, Depends(get_app)], user_id: Annotated[UserID, Depends(get_current_user_id)], @@ -61,7 +75,13 @@ async def get_async_jobs( return ApiServerEnvelope(data=data) -@router.get("/{task_id}", response_model=TaskStatus, name="get_async_job_status") +@router.get( + "/{task_id}", + response_model=TaskStatus, + name="get_async_job_status", + responses=_DEFAULT_TASK_STATUS_CODES, + status_code=status.HTTP_200_OK, +) async def get_async_job_status( task_id: AsyncJobId, user_id: Annotated[UserID, Depends(get_current_user_id)], @@ -83,7 +103,10 @@ async def get_async_job_status( @router.post( - "/{task_id}:cancel", status_code=status.HTTP_204_NO_CONTENT, name="cancel_async_job" + "/{task_id}:cancel", + status_code=status.HTTP_204_NO_CONTENT, + name="cancel_async_job", + responses=_DEFAULT_TASK_STATUS_CODES, ) async def cancel_async_job( task_id: AsyncJobId, @@ -97,7 +120,23 @@ async def cancel_async_job( ) -@router.get("/{task_id}/result", response_model=TaskResult, name="get_async_job_result") +@router.get( + "/{task_id}/result", + response_model=TaskResult, + name="get_async_job_result", + responses={ + status.HTTP_404_NOT_FOUND: { + "description": "Task result not found", + "model": ErrorGet, + }, + status.HTTP_409_CONFLICT: { + "description": "Task is cancelled", + "model": ErrorGet, + }, + **_DEFAULT_TASK_STATUS_CODES, + }, + status_code=status.HTTP_200_OK, +) async def get_async_job_result( task_id: AsyncJobId, user_id: Annotated[UserID, Depends(get_current_user_id)], From 56f01ffb302e7aeec8665a80f8dc60c4c608b0d0 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Thu, 3 Jul 2025 09:45:57 +0200 Subject: [PATCH 15/30] update openapi specs --- services/api-server/openapi.json | 360 ++++++++++++++++++ .../api/routes/tasks.py | 2 +- 2 files changed, 361 insertions(+), 1 deletion(-) diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index f35f4723d923..f197571273f0 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -7829,10 +7829,249 @@ } } } + }, + "/v0/tasks": { + "get": { + "tags": [ + "tasks" + ], + "summary": "Get Async Jobs", + "operationId": "get_async_jobs", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiServerEnvelope_list_TaskGet__" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorGet" + } + } + } + } + }, + "security": [ + { + "HTTPBasic": [] + } + ] + } + }, + "/v0/tasks/{task_id}": { + "get": { + "tags": [ + "tasks" + ], + "summary": "Get Async Job Status", + "operationId": "get_async_job_status", + "security": [ + { + "HTTPBasic": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskStatus" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorGet" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v0/tasks/{task_id}:cancel": { + "post": { + "tags": [ + "tasks" + ], + "summary": "Cancel Async Job", + "operationId": "cancel_async_job", + "security": [ + { + "HTTPBasic": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Task Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorGet" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v0/tasks/{task_id}/result": { + "get": { + "tags": [ + "tasks" + ], + "summary": "Get Async Job Result", + "operationId": "get_async_job_result", + "security": [ + { + "HTTPBasic": [] + } + ], + "parameters": [ + { + "name": "task_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Task Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskResult" + } + } + } + }, + "404": { + "description": "Task result not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorGet" + } + } + } + }, + "409": { + "description": "Task is cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorGet" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorGet" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { "schemas": { + "ApiServerEnvelope_list_TaskGet__": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/TaskGet" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiServerEnvelope[list[TaskGet]]" + }, "Body_abort_multipart_upload_v0_files__file_id__abort_post": { "properties": { "client_file": { @@ -11114,6 +11353,127 @@ "kind": "input" } }, + "TaskGet": { + "properties": { + "task_id": { + "type": "string", + "title": "Task Id" + }, + "task_name": { + "type": "string", + "title": "Task Name" + }, + "status_href": { + "type": "string", + "title": "Status Href" + }, + "result_href": { + "type": "string", + "title": "Result Href" + }, + "abort_href": { + "type": "string", + "title": "Abort Href" + } + }, + "type": "object", + "required": [ + "task_id", + "task_name", + "status_href", + "result_href", + "abort_href" + ], + "title": "TaskGet" + }, + "TaskProgress": { + "properties": { + "task_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + }, + "message": { + "type": "string", + "title": "Message", + "default": "" + }, + "percent": { + "type": "number", + "maximum": 1.0, + "minimum": 0.0, + "title": "Percent", + "default": 0.0 + } + }, + "type": "object", + "title": "TaskProgress", + "description": "Helps the user to keep track of the progress. Progress is expected to be\ndefined as a float bound between 0.0 and 1.0" + }, + "TaskResult": { + "properties": { + "result": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Result" + }, + "error": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Error" + } + }, + "type": "object", + "required": [ + "result", + "error" + ], + "title": "TaskResult" + }, + "TaskStatus": { + "properties": { + "task_progress": { + "$ref": "#/components/schemas/TaskProgress" + }, + "done": { + "type": "boolean", + "title": "Done" + }, + "started": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started" + } + }, + "type": "object", + "required": [ + "task_progress", + "done", + "started" + ], + "title": "TaskStatus" + }, "UnitExtraInfoTier": { "properties": { "CPU": { diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index 6ed9e0867c8c..5035c254bf87 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -13,11 +13,11 @@ AsyncJobNameData, ) from models_library.products import ProductName -from models_library.rest_error import ErrorGet from models_library.users import UserID from servicelib.fastapi.dependencies import get_app from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope +from ...models.schemas.errors import ErrorGet from ...services_rpc.async_jobs import AsyncJobClient from ..dependencies.authentication import get_current_user_id from ..dependencies.services import get_product_name From 97345ab2ac8bf6d6fc95bee46d40dc336d06e125 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Thu, 3 Jul 2025 11:11:28 +0200 Subject: [PATCH 16/30] @giancarloromeo absolute import -> relative import --- .../simcore_service_api_server/services_rpc/async_jobs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py b/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py index fd687967a9e4..9e263d755057 100644 --- a/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py +++ b/services/api-server/src/simcore_service_api_server/services_rpc/async_jobs.py @@ -17,15 +17,15 @@ from models_library.api_schemas_storage import STORAGE_RPC_NAMESPACE from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient from servicelib.rabbitmq.rpc_interfaces.async_jobs import async_jobs -from simcore_service_api_server.exceptions.task_errors import ( + +from ..exceptions.service_errors_utils import service_exception_mapper +from ..exceptions.task_errors import ( TaskCancelledError, TaskError, TaskResultMissingError, TaskSchedulerError, ) -from ..exceptions.service_errors_utils import service_exception_mapper - _exception_mapper = functools.partial( service_exception_mapper, service_name="Async jobs" ) From c5809c2a4751574feb0140b48b8b2462d46f59bd Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Thu, 3 Jul 2025 11:40:33 +0200 Subject: [PATCH 17/30] pylint --- services/api-server/tests/unit/test_tasks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/api-server/tests/unit/test_tasks.py b/services/api-server/tests/unit/test_tasks.py index 0ff3e5e15821..eb92caf5fa5a 100644 --- a/services/api-server/tests/unit/test_tasks.py +++ b/services/api-server/tests/unit/test_tasks.py @@ -1,3 +1,6 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument + from typing import Any import pytest From 3c0d2a36c0331161f55268aae6665b735b8272a3 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Thu, 3 Jul 2025 11:41:38 +0200 Subject: [PATCH 18/30] fix indirect import --- .../src/simcore_service_api_server/api/routes/tasks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index 5035c254bf87..e52e325c93c3 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -19,8 +19,7 @@ from ...models.schemas.errors import ErrorGet from ...services_rpc.async_jobs import AsyncJobClient -from ..dependencies.authentication import get_current_user_id -from ..dependencies.services import get_product_name +from ..dependencies.authentication import get_current_user_id, get_product_name from ..dependencies.tasks import get_async_jobs_client router = APIRouter() From cdd7f2ff24e43a24134b05c7f9ef5f4adb0d9b06 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Thu, 3 Jul 2025 12:20:22 +0200 Subject: [PATCH 19/30] pylint --- .../src/pytest_simcore/helpers/async_jobs_server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py b/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py index 70cfd3f4c171..369396153efe 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/async_jobs_server.py @@ -1,3 +1,5 @@ +# pylint: disable=unused-argument + from dataclasses import dataclass from models_library.api_schemas_rpc_async_jobs.async_jobs import ( From c607e35ee5f79f399d75e4e826c12a9309396eb1 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Fri, 4 Jul 2025 13:45:46 +0200 Subject: [PATCH 20/30] @pcrespov add support_id to error model --- .../handlers/_handlers_backend_errors.py | 14 ++++++++------ .../exceptions/handlers/_utils.py | 5 +++-- .../models/schemas/errors.py | 2 ++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py index 3ae63fd2894a..9d852b472ad0 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py @@ -1,8 +1,8 @@ import logging from common_library.error_codes import create_error_code -from fastapi import status from servicelib.logging_errors import create_troubleshootting_log_kwargs +from servicelib.status_codes_utils import is_5xx_server_error from starlette.requests import Request from starlette.responses import JSONResponse @@ -16,15 +16,17 @@ async def backend_error_handler(request: Request, exc: Exception) -> JSONRespons assert request # nosec assert isinstance(exc, BaseBackEndError) user_error_msg = f"{exc}" - if not exc.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR: - oec = create_error_code(exc) - user_error_msg += f" [{oec}]" + support_id = None + if is_5xx_server_error(exc.status_code): + support_id = create_error_code(exc) _logger.exception( **create_troubleshootting_log_kwargs( user_error_msg, error=exc, - error_code=oec, + error_code=support_id, tip="Unexpected error", ) ) - return create_error_json_response(user_error_msg, status_code=exc.status_code) + return create_error_json_response( + user_error_msg, status_code=exc.status_code, support_id=support_id + ) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_utils.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_utils.py index da741fdb8b45..2c0024476cc9 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_utils.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_utils.py @@ -1,6 +1,7 @@ from collections.abc import Awaitable, Callable from typing import Any, TypeAlias +from common_library.error_codes import ErrorCodeStr from fastapi.encoders import jsonable_encoder from fastapi.requests import Request from fastapi.responses import JSONResponse @@ -13,13 +14,13 @@ def create_error_json_response( - *errors: Any, status_code: int, **kwargs + *errors: Any, status_code: int, support_id: ErrorCodeStr | None = None, **kwargs ) -> JSONResponse: """ Converts errors to Error response model defined in the OAS """ - error_model = ErrorGet(errors=list(errors)) + error_model = ErrorGet(errors=list(errors), support_id=support_id, **kwargs) return JSONResponse( content=jsonable_encoder(error_model), status_code=status_code, diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/errors.py b/services/api-server/src/simcore_service_api_server/models/schemas/errors.py index 3243f5e44b98..69d6f7d67003 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/errors.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/errors.py @@ -1,5 +1,6 @@ from typing import Any +from common_library.error_codes import ErrorCodeStr from pydantic import BaseModel, ConfigDict @@ -10,6 +11,7 @@ class ErrorGet(BaseModel): # - https://github.com/ITISFoundation/osparc-simcore/issues/2520 # - https://github.com/ITISFoundation/osparc-simcore/issues/2446 errors: list[Any] + support_id: ErrorCodeStr | None = None model_config = ConfigDict( json_schema_extra={ From 21d8b1aa2b1845ffdaaa39ec71fe5054c260b95d Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Fri, 4 Jul 2025 13:47:34 +0200 Subject: [PATCH 21/30] add support_id to error model in api-server --- services/api-server/openapi.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index f197571273f0..935b3a431d53 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -8216,6 +8216,18 @@ "items": {}, "type": "array", "title": "Errors" + }, + "support_id": { + "anyOf": [ + { + "type": "string", + "pattern": "OEC:([a-fA-F0-9]{12})-(\\d{13,14})" + }, + { + "type": "null" + } + ], + "title": "Support Id" } }, "type": "object", From 5c8c1cdf86698e011d4174dae19154610a456ff7 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Fri, 4 Jul 2025 13:53:02 +0200 Subject: [PATCH 22/30] =?UTF-8?q?services/api-server=20version:=200.9.0=20?= =?UTF-8?q?=E2=86=92=200.9.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/api-server/VERSION | 2 +- services/api-server/setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/api-server/VERSION b/services/api-server/VERSION index ac39a106c485..f374f6662e9a 100644 --- a/services/api-server/VERSION +++ b/services/api-server/VERSION @@ -1 +1 @@ -0.9.0 +0.9.1 diff --git a/services/api-server/setup.cfg b/services/api-server/setup.cfg index 21f72b9aecc1..6f7fe90adca5 100644 --- a/services/api-server/setup.cfg +++ b/services/api-server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.9.0 +current_version = 0.9.1 commit = True message = services/api-server version: {current_version} → {new_version} tag = False From 77d3283b50eaf449bdd004291bf9653b91bfa3bd Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Fri, 4 Jul 2025 13:55:07 +0200 Subject: [PATCH 23/30] update openapi specs --- services/api-server/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index 935b3a431d53..50b5c9ee7f22 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "osparc.io public API", "description": "osparc-simcore public API specifications", - "version": "0.9.0" + "version": "0.9.1" }, "paths": { "/v0/meta": { From 5222258d2967f97f20f8c686c92965082ed99184 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Fri, 4 Jul 2025 14:04:23 +0200 Subject: [PATCH 24/30] hide task endpoints from api --- .../api/routes/tasks.py | 58 ++++++++++++++----- .../models/schemas/{_base.py => base.py} | 11 +++- .../models/schemas/files.py | 2 +- .../models/schemas/jobs.py | 2 +- .../models/schemas/programs.py | 2 +- .../models/schemas/solvers.py | 2 +- .../models/schemas/tasks.py | 9 --- 7 files changed, 58 insertions(+), 28 deletions(-) rename services/api-server/src/simcore_service_api_server/models/schemas/{_base.py => base.py} (93%) delete mode 100644 services/api-server/src/simcore_service_api_server/models/schemas/tasks.py diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index e52e325c93c3..6bbd2aae757a 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -1,6 +1,7 @@ import logging from typing import Annotated, Any +from common_library.changelog import create_route_description from fastapi import APIRouter, Depends, FastAPI, status from models_library.api_schemas_long_running_tasks.base import TaskProgress from models_library.api_schemas_long_running_tasks.tasks import ( @@ -15,12 +16,16 @@ from models_library.products import ProductName from models_library.users import UserID from servicelib.fastapi.dependencies import get_app -from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope +from ...models.schemas.base import ApiServerEnvelope from ...models.schemas.errors import ErrorGet from ...services_rpc.async_jobs import AsyncJobClient from ..dependencies.authentication import get_current_user_id, get_product_name from ..dependencies.tasks import get_async_jobs_client +from ._constants import ( + FMSG_CHANGELOG_NEW_IN_VERSION, + create_route_description, +) router = APIRouter() _logger = logging.getLogger(__name__) @@ -43,8 +48,16 @@ def _get_job_id_data(user_id: UserID, product_name: ProductName) -> AsyncJobName response_model=ApiServerEnvelope[list[TaskGet]], responses=_DEFAULT_TASK_STATUS_CODES, status_code=status.HTTP_200_OK, + name="list_tasks", + description=create_route_description( + base="List all tasks", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.10-rc1"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.10-rc1 ) -async def get_async_jobs( +async def list_tasks( app: Annotated[FastAPI, Depends(get_app)], user_id: Annotated[UserID, Depends(get_current_user_id)], product_name: Annotated[ProductName, Depends(get_product_name)], @@ -60,13 +73,11 @@ async def get_async_jobs( task_id=f"{job.job_id}", task_name=job.job_name, status_href=app_router.url_path_for( - "get_async_job_status", task_id=f"{job.job_id}" - ), - abort_href=app_router.url_path_for( - "cancel_async_job", task_id=f"{job.job_id}" + "get_task_status", task_id=f"{job.job_id}" ), + abort_href=app_router.url_path_for("cancel_task", task_id=f"{job.job_id}"), result_href=app_router.url_path_for( - "get_async_job_result", task_id=f"{job.job_id}" + "get_task_result", task_id=f"{job.job_id}" ), ) for job in user_async_jobs @@ -77,11 +88,18 @@ async def get_async_jobs( @router.get( "/{task_id}", response_model=TaskStatus, - name="get_async_job_status", + name="get_task_status", responses=_DEFAULT_TASK_STATUS_CODES, status_code=status.HTTP_200_OK, + description=create_route_description( + base="Get task status", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.10-rc1"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.10-rc1 ) -async def get_async_job_status( +async def get_task_status( task_id: AsyncJobId, user_id: Annotated[UserID, Depends(get_current_user_id)], product_name: Annotated[ProductName, Depends(get_product_name)], @@ -104,10 +122,17 @@ async def get_async_job_status( @router.post( "/{task_id}:cancel", status_code=status.HTTP_204_NO_CONTENT, - name="cancel_async_job", + name="cancel_task", responses=_DEFAULT_TASK_STATUS_CODES, + description=create_route_description( + base="Cancel task", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.10-rc1"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.10-rc1 ) -async def cancel_async_job( +async def cancel_task( task_id: AsyncJobId, user_id: Annotated[UserID, Depends(get_current_user_id)], product_name: Annotated[ProductName, Depends(get_product_name)], @@ -122,7 +147,7 @@ async def cancel_async_job( @router.get( "/{task_id}/result", response_model=TaskResult, - name="get_async_job_result", + name="get_task_result", responses={ status.HTTP_404_NOT_FOUND: { "description": "Task result not found", @@ -135,8 +160,15 @@ async def cancel_async_job( **_DEFAULT_TASK_STATUS_CODES, }, status_code=status.HTTP_200_OK, + description=create_route_description( + base="Get task result", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.10-rc1"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.10-rc1 ) -async def get_async_job_result( +async def get_task_result( task_id: AsyncJobId, user_id: Annotated[UserID, Depends(get_current_user_id)], product_name: Annotated[ProductName, Depends(get_product_name)], diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/_base.py b/services/api-server/src/simcore_service_api_server/models/schemas/base.py similarity index 93% rename from services/api-server/src/simcore_service_api_server/models/schemas/_base.py rename to services/api-server/src/simcore_service_api_server/models/schemas/base.py index 07144ba5b766..6abe72ca9f14 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/_base.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/base.py @@ -1,12 +1,12 @@ import urllib.parse -from typing import Annotated +from typing import Annotated, Generic, TypeVar import packaging.version from models_library.utils.change_case import camel_to_snake from models_library.utils.common_validators import trim_string_before from pydantic import BaseModel, ConfigDict, Field, HttpUrl, StringConstraints -from ...models._utils_pydantic import UriSchema +from .._utils_pydantic import UriSchema from ..basic_types import VersionStr @@ -83,3 +83,10 @@ def name(self) -> str: @classmethod def compose_resource_name(cls, key: str, version: str) -> str: raise NotImplementedError("Subclasses must implement this method") + + +DataT = TypeVar("DataT") + + +class ApiServerEnvelope(BaseModel, Generic[DataT]): + data: DataT diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/files.py b/services/api-server/src/simcore_service_api_server/models/schemas/files.py index ebfee726adbc..5c6ea3b3bd89 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/files.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/files.py @@ -18,7 +18,7 @@ from .._utils_pydantic import UriSchema from ..domain.files import File as DomainFile from ..domain.files import FileName -from ._base import ApiServerInputSchema, ApiServerOutputSchema +from .base import ApiServerInputSchema, ApiServerOutputSchema class UserFile(ApiServerInputSchema): diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py b/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py index 86abb0a87414..08bfe901bbc4 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py @@ -39,7 +39,7 @@ from ..domain.files import File as DomainFile from ..domain.files import FileInProgramJobData from ..schemas.files import UserFile -from ._base import ApiServerInputSchema +from .base import ApiServerInputSchema # JOB SUB-RESOURCES ---------- # diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/programs.py b/services/api-server/src/simcore_service_api_server/models/schemas/programs.py index 305f9eb28cf7..ca3e0c564ddd 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/programs.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/programs.py @@ -9,7 +9,7 @@ from ..api_resources import compose_resource_name from ..basic_types import VersionStr -from ._base import ( +from .base import ( ApiServerOutputSchema, BaseService, ) diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py b/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py index 63a383c4f2b1..d9b70e68f84c 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py @@ -13,8 +13,8 @@ from models_library.services_types import ServiceKey from pydantic import BaseModel, ConfigDict, Field, StringConstraints -from ...models.schemas._base import BaseService from ..api_resources import compose_resource_name +from .base import BaseService # NOTE: # - API does NOT impose prefix (simcore)/(services)/comp because does not know anything about registry deployed. This constraint diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/tasks.py b/services/api-server/src/simcore_service_api_server/models/schemas/tasks.py deleted file mode 100644 index 06dddc9583f7..000000000000 --- a/services/api-server/src/simcore_service_api_server/models/schemas/tasks.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Generic, TypeVar - -from pydantic import BaseModel - -DataT = TypeVar("DataT") - - -class ApiServerEnvelope(BaseModel, Generic[DataT]): - data: DataT From aaac3a231ccc707055cd932b863570745b9a98ea Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Fri, 4 Jul 2025 14:05:41 +0200 Subject: [PATCH 25/30] update openapi specs --- services/api-server/openapi.json | 360 ------------------------------- 1 file changed, 360 deletions(-) diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index 50b5c9ee7f22..6d57f716398a 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -7829,249 +7829,10 @@ } } } - }, - "/v0/tasks": { - "get": { - "tags": [ - "tasks" - ], - "summary": "Get Async Jobs", - "operationId": "get_async_jobs", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiServerEnvelope_list_TaskGet__" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorGet" - } - } - } - } - }, - "security": [ - { - "HTTPBasic": [] - } - ] - } - }, - "/v0/tasks/{task_id}": { - "get": { - "tags": [ - "tasks" - ], - "summary": "Get Async Job Status", - "operationId": "get_async_job_status", - "security": [ - { - "HTTPBasic": [] - } - ], - "parameters": [ - { - "name": "task_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Task Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TaskStatus" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorGet" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/v0/tasks/{task_id}:cancel": { - "post": { - "tags": [ - "tasks" - ], - "summary": "Cancel Async Job", - "operationId": "cancel_async_job", - "security": [ - { - "HTTPBasic": [] - } - ], - "parameters": [ - { - "name": "task_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Task Id" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorGet" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/v0/tasks/{task_id}/result": { - "get": { - "tags": [ - "tasks" - ], - "summary": "Get Async Job Result", - "operationId": "get_async_job_result", - "security": [ - { - "HTTPBasic": [] - } - ], - "parameters": [ - { - "name": "task_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Task Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TaskResult" - } - } - } - }, - "404": { - "description": "Task result not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorGet" - } - } - } - }, - "409": { - "description": "Task is cancelled", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorGet" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorGet" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } } }, "components": { "schemas": { - "ApiServerEnvelope_list_TaskGet__": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/TaskGet" - }, - "type": "array", - "title": "Data" - } - }, - "type": "object", - "required": [ - "data" - ], - "title": "ApiServerEnvelope[list[TaskGet]]" - }, "Body_abort_multipart_upload_v0_files__file_id__abort_post": { "properties": { "client_file": { @@ -11365,127 +11126,6 @@ "kind": "input" } }, - "TaskGet": { - "properties": { - "task_id": { - "type": "string", - "title": "Task Id" - }, - "task_name": { - "type": "string", - "title": "Task Name" - }, - "status_href": { - "type": "string", - "title": "Status Href" - }, - "result_href": { - "type": "string", - "title": "Result Href" - }, - "abort_href": { - "type": "string", - "title": "Abort Href" - } - }, - "type": "object", - "required": [ - "task_id", - "task_name", - "status_href", - "result_href", - "abort_href" - ], - "title": "TaskGet" - }, - "TaskProgress": { - "properties": { - "task_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Task Id" - }, - "message": { - "type": "string", - "title": "Message", - "default": "" - }, - "percent": { - "type": "number", - "maximum": 1.0, - "minimum": 0.0, - "title": "Percent", - "default": 0.0 - } - }, - "type": "object", - "title": "TaskProgress", - "description": "Helps the user to keep track of the progress. Progress is expected to be\ndefined as a float bound between 0.0 and 1.0" - }, - "TaskResult": { - "properties": { - "result": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "title": "Result" - }, - "error": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "title": "Error" - } - }, - "type": "object", - "required": [ - "result", - "error" - ], - "title": "TaskResult" - }, - "TaskStatus": { - "properties": { - "task_progress": { - "$ref": "#/components/schemas/TaskProgress" - }, - "done": { - "type": "boolean", - "title": "Done" - }, - "started": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Started" - } - }, - "type": "object", - "required": [ - "task_progress", - "done", - "started" - ], - "title": "TaskStatus" - }, "UnitExtraInfoTier": { "properties": { "CPU": { From fa695f2acd5f0bfa4fad647d1de66a1e14e8c636 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Fri, 4 Jul 2025 14:11:18 +0200 Subject: [PATCH 26/30] fix import --- services/api-server/tests/unit/test_tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/api-server/tests/unit/test_tasks.py b/services/api-server/tests/unit/test_tasks.py index eb92caf5fa5a..40f64eb31c44 100644 --- a/services/api-server/tests/unit/test_tasks.py +++ b/services/api-server/tests/unit/test_tasks.py @@ -17,7 +17,7 @@ ) from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.async_jobs_server import AsyncJobSideEffects -from simcore_service_api_server.models.schemas.tasks import ApiServerEnvelope +from simcore_service_api_server.models.schemas.base import ApiServerEnvelope _faker = Faker() From 8d9ba17047422403fb8625d6288b0e73427e7d84 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Fri, 4 Jul 2025 14:13:43 +0200 Subject: [PATCH 27/30] fix typecheck --- .../src/simcore_service_api_server/api/routes/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index 6bbd2aae757a..67acd5215a22 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -1,7 +1,6 @@ import logging from typing import Annotated, Any -from common_library.changelog import create_route_description from fastapi import APIRouter, Depends, FastAPI, status from models_library.api_schemas_long_running_tasks.base import TaskProgress from models_library.api_schemas_long_running_tasks.tasks import ( From 7d111658d1711b1c04e4064a85c2e17154a47057 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Fri, 4 Jul 2025 23:46:33 +0200 Subject: [PATCH 28/30] @pcrespov remove default values --- .../src/simcore_service_api_server/api/routes/tasks.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py index 67acd5215a22..2c3f6b2f66cd 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/tasks.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/tasks.py @@ -46,8 +46,6 @@ def _get_job_id_data(user_id: UserID, product_name: ProductName) -> AsyncJobName "", response_model=ApiServerEnvelope[list[TaskGet]], responses=_DEFAULT_TASK_STATUS_CODES, - status_code=status.HTTP_200_OK, - name="list_tasks", description=create_route_description( base="List all tasks", changelog=[ @@ -87,9 +85,7 @@ async def list_tasks( @router.get( "/{task_id}", response_model=TaskStatus, - name="get_task_status", responses=_DEFAULT_TASK_STATUS_CODES, - status_code=status.HTTP_200_OK, description=create_route_description( base="Get task status", changelog=[ @@ -121,7 +117,6 @@ async def get_task_status( @router.post( "/{task_id}:cancel", status_code=status.HTTP_204_NO_CONTENT, - name="cancel_task", responses=_DEFAULT_TASK_STATUS_CODES, description=create_route_description( base="Cancel task", @@ -146,7 +141,6 @@ async def cancel_task( @router.get( "/{task_id}/result", response_model=TaskResult, - name="get_task_result", responses={ status.HTTP_404_NOT_FOUND: { "description": "Task result not found", @@ -158,7 +152,6 @@ async def cancel_task( }, **_DEFAULT_TASK_STATUS_CODES, }, - status_code=status.HTTP_200_OK, description=create_route_description( base="Get task result", changelog=[ From 088ad73be3f9a4cd4fc9b6fc73ec714891f898ee Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Fri, 4 Jul 2025 23:49:20 +0200 Subject: [PATCH 29/30] use BaseBackendError as type hint --- .../exceptions/handlers/_handlers_backend_errors.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py index 9d852b472ad0..107936acae28 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py @@ -12,7 +12,9 @@ _logger = logging.getLogger(__name__) -async def backend_error_handler(request: Request, exc: Exception) -> JSONResponse: +async def backend_error_handler( + request: Request, exc: BaseBackEndError +) -> JSONResponse: assert request # nosec assert isinstance(exc, BaseBackEndError) user_error_msg = f"{exc}" From fc20bab69ec452db288a5547be253087ef36f340 Mon Sep 17 00:00:00 2001 From: Mads Bisgaard Date: Mon, 7 Jul 2025 07:34:50 +0200 Subject: [PATCH 30/30] fix typecheck --- .../exceptions/handlers/_handlers_backend_errors.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py index 107936acae28..9d852b472ad0 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py @@ -12,9 +12,7 @@ _logger = logging.getLogger(__name__) -async def backend_error_handler( - request: Request, exc: BaseBackEndError -) -> JSONResponse: +async def backend_error_handler(request: Request, exc: Exception) -> JSONResponse: assert request # nosec assert isinstance(exc, BaseBackEndError) user_error_msg = f"{exc}"