From 349e78c75835629fb91f3cb8d905cc10ff0fa063 Mon Sep 17 00:00:00 2001 From: Hamza Rashid <74062092+HzaRashid@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:08:40 -0400 Subject: [PATCH] fix: propagate resource-specific conflict error to api (#12580) * fix: preserve resource-specific conflict mapping in wxo deployment flow - rename DeploymentConflictError to ResourceConflictError (with a backward-compatible alias) and propagate resource/resource_name across deployment error handling - extend raise_for_status_and_detail to accept explicit conflict hints and only infer resource/resource_name from provider detail as a fallback - update deployment mappers/helpers to use resource/resource_name and format conflict details from structured fields - add explicit conflict metadata at known wxo catch/re-raise points (connection/tool/agent paths) and enforce hint passing through raise_as_deployment_error - prevent create/update top-level handlers from over-tagging conflicts as agent when downstream errors are tool/connection conflicts - centralize create-agent provider error translation via raise_as_deployment_error - add/adjust unit tests for route handlers, mapper conflict formatting, wxo service conflict propagation, and lfx deployment exception behavior (including Simple_Agent regression) * remove DeploymentConflictError shim * pass resource_name only on creation paths * allow None * fix(deployments): simplify conflict mapping and tighten error handling Remove conflict-hint inference fallback, keep pass-through conflict detail formatting, tighten ClientAPIException status extraction, and drop temporary debug prints in deployment error paths. * fix(deployments): simplify conflict hint mapping and align test expectations Remove redundant conflict-hint normalization in deployment exceptions, clarify base mapper conflict-detail docs, and improve invalid flow-version guidance. Update watsonx deployment tests to assert current service-layer conflict/resource and exception-chain behavior. * get rid of tool name fallbacks * hard code fallback message --- .../deployment_watsonx_adapter_e2e.py | 34 ++-- .../api/v1/mappers/deployments/base.py | 16 +- .../api/v1/mappers/deployments/helpers.py | 15 +- .../deployments/watsonx_orchestrate/mapper.py | 42 ++++- .../watsonx_orchestrate/core/create.py | 12 +- .../watsonx_orchestrate/core/retry.py | 4 +- .../watsonx_orchestrate/core/shared.py | 4 +- .../watsonx_orchestrate/core/tools.py | 47 +++-- .../deployment/watsonx_orchestrate/service.py | 12 +- .../deployment/watsonx_orchestrate/utils.py | 14 +- .../api/v1/test_deployment_mapper_base.py | 5 +- .../api/v1/test_deployment_mapper_watsonx.py | 77 ++++++-- .../api/v1/test_deployment_route_handlers.py | 50 ++++- .../deployment/test_watsonx_orchestrate.py | 178 ++++++++++++++++-- .../adapters/deployment/exceptions.py | 39 +++- .../deployment/test_deployment_exceptions.py | 96 ++++++---- 16 files changed, 505 insertions(+), 140 deletions(-) diff --git a/scripts/langflow_deployments_api/adapters/deployment_watsonx_adapter_e2e.py b/scripts/langflow_deployments_api/adapters/deployment_watsonx_adapter_e2e.py index 1db5e21521..1b12c7e0f7 100644 --- a/scripts/langflow_deployments_api/adapters/deployment_watsonx_adapter_e2e.py +++ b/scripts/langflow_deployments_api/adapters/deployment_watsonx_adapter_e2e.py @@ -82,7 +82,7 @@ Live update-matrix scenarios: in provider_data (expects InvalidContentError). - `upd_missing_add_id_fails`: rejects unknown bind.tool.tool_id_with_ref in provider_data (expects InvalidContentError). - `upd_config_raw_payload_conflict`: detects conflict when creating duplicate provider_data - raw connection app id (expects DeploymentConflictError). + raw connection app id (expects ResourceConflictError). - `upd_not_found_deployment`: update unknown deployment id returns not found (expects DeploymentNotFoundError). - `upd_put_tools_replaces_tool_list`: uses put_tools to declaratively replace the agent's tool list with a subset (expects Success). @@ -94,7 +94,7 @@ Live update-matrix scenarios: Failpoint scenarios: - `fp_retry_create_config_then_success`: injects transient config-create failures; retries then succeeds (expects Success). -- `fp_non_retryable_create_agent_conflict`: injects non-retryable agent conflict (expects DeploymentConflictError). +- `fp_non_retryable_create_agent_conflict`: injects non-retryable agent conflict (expects ResourceConflictError). - `fp_create_agent_failure_triggers_rollback`: injects repeated agent-create failure and checks rollback (expects DeploymentError). - `fp_update_bindings_failure_triggers_rollback`: injects update-stage binding failure @@ -141,12 +141,12 @@ from langflow.services.adapters.deployment.watsonx_orchestrate.payloads import ( WatsonxFlowArtifactProviderData, ) from lfx.services.adapters.deployment.exceptions import ( - DeploymentConflictError, DeploymentError, DeploymentNotFoundError, InvalidContentError, InvalidDeploymentOperationError, InvalidDeploymentTypeError, + ResourceConflictError, ) from lfx.services.adapters.deployment.schema import ( BaseDeploymentData, @@ -164,7 +164,7 @@ from lfx.services.adapters.deployment.schema import ( OUTCOME_SUCCESS = "Success" OUTCOME_INVALID_OPERATION = "InvalidDeploymentOperationError" -OUTCOME_CONFLICT = "DeploymentConflictError" +OUTCOME_CONFLICT = "ResourceConflictError" OUTCOME_INVALID_CONTENT = "InvalidContentError" OUTCOME_FAILURE = "DeploymentError" OUTCOME_NOT_FOUND = "DeploymentNotFoundError" @@ -428,7 +428,7 @@ class WatsonxAdapterDirectE2E: self._apply_injections(inject, originals) result = await self.service.create(user_id=self.user_id, payload=payload, db=self.db) - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -456,7 +456,7 @@ class WatsonxAdapterDirectE2E: result = await self.service.list(user_id=self.user_id, db=self.db, params=params) except DeploymentNotFoundError as exc: return OUTCOME_NOT_FOUND, str(exc), None - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -476,7 +476,7 @@ class WatsonxAdapterDirectE2E: result = await self.service.get(user_id=self.user_id, deployment_id=deployment_id, db=self.db) except DeploymentNotFoundError as exc: return OUTCOME_NOT_FOUND, str(exc), None - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -510,7 +510,7 @@ class WatsonxAdapterDirectE2E: ) except DeploymentNotFoundError as exc: return OUTCOME_NOT_FOUND, str(exc), None - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -538,7 +538,7 @@ class WatsonxAdapterDirectE2E: ) except DeploymentNotFoundError as exc: return OUTCOME_NOT_FOUND, str(exc), None - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -562,7 +562,7 @@ class WatsonxAdapterDirectE2E: ) except DeploymentNotFoundError as exc: return OUTCOME_NOT_FOUND, str(exc), None - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -586,7 +586,7 @@ class WatsonxAdapterDirectE2E: ) except DeploymentNotFoundError as exc: return OUTCOME_NOT_FOUND, str(exc), None - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -606,7 +606,7 @@ class WatsonxAdapterDirectE2E: result = await self.service.get_status(user_id=self.user_id, deployment_id=deployment_id, db=self.db) except DeploymentNotFoundError as exc: return OUTCOME_NOT_FOUND, str(exc), None - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -635,7 +635,7 @@ class WatsonxAdapterDirectE2E: ) except DeploymentNotFoundError as exc: return OUTCOME_NOT_FOUND, str(exc), None - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -655,7 +655,7 @@ class WatsonxAdapterDirectE2E: result = await self.service.get_execution(user_id=self.user_id, execution_id=execution_id, db=self.db) except DeploymentNotFoundError as exc: return OUTCOME_NOT_FOUND, str(exc), None - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -695,7 +695,7 @@ class WatsonxAdapterDirectE2E: result = await self.service.delete(user_id=self.user_id, deployment_id=deployment_id, db=self.db) except DeploymentNotFoundError as exc: return OUTCOME_NOT_FOUND, str(exc), None - except DeploymentConflictError as exc: + except ResourceConflictError as exc: return OUTCOME_CONFLICT, exc.message, None except InvalidContentError as exc: return OUTCOME_INVALID_CONTENT, exc.message, None @@ -2793,7 +2793,7 @@ class WatsonxAdapterDirectE2E: __ctr["value"] += 1 if __ctr["value"] <= __n: if __type == "domain_conflict": - raise DeploymentConflictError(message=__msg) + raise ResourceConflictError(message=__msg) if __type == "domain_not_found": raise DeploymentNotFoundError(message=__msg) if __type == "domain_invalid_content": @@ -2821,7 +2821,7 @@ class WatsonxAdapterDirectE2E: __ctr["value"] += 1 if __ctr["value"] <= __n: if __type == "domain_conflict": - raise DeploymentConflictError(message=__msg) + raise ResourceConflictError(message=__msg) if __type == "domain_not_found": raise DeploymentNotFoundError(message=__msg) if __type == "domain_invalid_content": diff --git a/src/backend/base/langflow/api/v1/mappers/deployments/base.py b/src/backend/base/langflow/api/v1/mappers/deployments/base.py index 2e5caebc7d..ce921dab89 100644 --- a/src/backend/base/langflow/api/v1/mappers/deployments/base.py +++ b/src/backend/base/langflow/api/v1/mappers/deployments/base.py @@ -412,13 +412,23 @@ class BaseDeploymentMapper: _ = provider_data raise NotImplementedError - def format_conflict_detail(self, raw_message: str) -> str: + def format_conflict_detail( + self, + raw_message: str, + *, + resource: str | None = None, + resource_name: str | None = None, + ) -> str: """Format provider conflict errors for API responses. Provider-specific mappers may override this to map provider-native - conflict wording to clearer end-user guidance. + conflict wording to clearer end-user guidance. Subclasses use + *resource* and *resource_name* to produce targeted messages. """ - return f"A resource with this name already exists in the provider. {raw_message}" + _ = raw_message, resource, resource_name + return ( + "A resource conflict occurred in the deployment provider. The requested operation could not be completed." + ) def resolve_credentials( self, diff --git a/src/backend/base/langflow/api/v1/mappers/deployments/helpers.py b/src/backend/base/langflow/api/v1/mappers/deployments/helpers.py index 1b1ab6f128..86aee2aef3 100644 --- a/src/backend/base/langflow/api/v1/mappers/deployments/helpers.py +++ b/src/backend/base/langflow/api/v1/mappers/deployments/helpers.py @@ -11,8 +11,8 @@ from fastapi import HTTPException, Query, status from fastapi_pagination import Params from lfx.log.logger import logger from lfx.services.adapters.deployment.exceptions import ( - DeploymentConflictError, DeploymentServiceError, + ResourceConflictError, http_status_for_deployment_error, ) from lfx.services.adapters.deployment.schema import ( @@ -151,7 +151,10 @@ async def build_flow_artifacts_from_flow_versions( ) rows = list((await db.exec(statement)).all()) if len(rows) < len(flow_version_ids): - msg = "One or more flow version ids are invalid." + msg = ( + "One or more flow versions are invalid. " + "Please ensure the flows belong to the project containing the deployment." + ) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=msg) artifacts: list[tuple[UUID, int, UUID, BaseFlowArtifact]] = [] @@ -315,8 +318,12 @@ def handle_adapter_errors(*, mapper: BaseDeploymentMapper | None = None): except DeploymentServiceError as exc: http_status = http_status_for_deployment_error(exc) detail = exc.message - if isinstance(exc, DeploymentConflictError) and mapper is not None: - detail = mapper.format_conflict_detail(exc.message) + if isinstance(exc, ResourceConflictError) and mapper is not None: + detail = mapper.format_conflict_detail( + exc.message, + resource=exc.resource, + resource_name=exc.resource_name, + ) logger.exception("Adapter error (status=%s): %s", http_status, detail) raise HTTPException( status_code=http_status, diff --git a/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py b/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py index 1177901eb9..716419f9be 100644 --- a/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py +++ b/src/backend/base/langflow/api/v1/mappers/deployments/watsonx_orchestrate/mapper.py @@ -247,21 +247,43 @@ class WatsonxOrchestrateDeploymentMapper(BaseDeploymentMapper): ) -> str: return self._parse_and_check_url(provider_data).url - def format_conflict_detail(self, raw_message: str) -> str: - lower = raw_message.lower() - if "agent" in lower and ("already exists" in lower or "conflict" in lower): + def format_conflict_detail( + self, + raw_message: str, + *, + resource: str | None = None, + resource_name: str | None = None, + ) -> str: + normalized_resource_name = str(resource_name or "").strip() or None + if resource == "tool": return ( - "An agent with this name already exists in the provider. " - "Please choose a different name or delete the existing agent first." + f"A tool with name '{normalized_resource_name}' already exists in the provider. " + "Please choose a different name." + if normalized_resource_name + else "A tool with this name already exists in the provider. Please choose a different name." ) - if ("connection" in lower or "app_id" in lower) and ("already exists" in lower or "conflict" in lower): + if resource == "connection": return ( - "A connection referenced in this request already exists in the provider. " + f"A connection with app_id '{normalized_resource_name}' already exists in the provider. " + "Reference it as an existing connection instead of creating a new one." + if normalized_resource_name + else "A connection referenced in this request already exists in the provider. " "Reference it as an existing connection instead of creating a new one." ) - if "tool" in lower and ("already exists" in lower or "conflict" in lower): - return "A tool with this name already exists in the provider. Please choose a different name." - return super().format_conflict_detail(raw_message) + if resource == "agent": + return ( + f"An agent with name '{normalized_resource_name}' already exists in the provider. " + "Please choose a different name or delete the existing agent first." + if normalized_resource_name + else "An agent with this name already exists in the provider. " + "Please choose a different name or delete the existing agent first." + ) + + return super().format_conflict_detail( + raw_message, + resource=resource, + resource_name=resource_name, + ) def _parse_and_check_url( self, diff --git a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/create.py b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/create.py index e5023893ee..caa3d55c9f 100644 --- a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/create.py +++ b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/create.py @@ -52,6 +52,7 @@ from langflow.services.adapters.deployment.watsonx_orchestrate.payloads import ( from langflow.services.adapters.deployment.watsonx_orchestrate.utils import ( build_agent_payload_from_values, dedupe_list, + raise_as_deployment_error, validate_wxo_name, ) @@ -425,7 +426,16 @@ async def create_agent_deployment( tool_ids=tool_ids, llm=llm, ) - return await asyncio.to_thread(clients.agent.create, payload) + try: + return await asyncio.to_thread(clients.agent.create, payload) + except Exception as exc: # noqa: BLE001 + raise_as_deployment_error( + exc, + error_prefix=ErrorPrefix.CREATE, + log_msg="Unexpected provider error during wxO agent create", + resource="agent", + resource_name=agent_name, + ) def validate_create_flow_provider_data( diff --git a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/retry.py b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/retry.py index e8b71f52bd..b81ff81ccb 100644 --- a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/retry.py +++ b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/retry.py @@ -11,10 +11,10 @@ from fastapi import HTTPException, status from ibm_watsonx_orchestrate_clients.tools.tool_client import ClientAPIException from lfx.log.logger import logger from lfx.services.adapters.deployment.exceptions import ( - DeploymentConflictError, InvalidContentError, InvalidDeploymentOperationError, InvalidDeploymentTypeError, + ResourceConflictError, ) from langflow.services.adapters.deployment.watsonx_orchestrate.constants import ( @@ -104,7 +104,7 @@ def is_retryable_create_exception(exc: Exception) -> bool: return not isinstance( exc, ( - DeploymentConflictError, + ResourceConflictError, InvalidContentError, InvalidDeploymentOperationError, InvalidDeploymentTypeError, diff --git a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/shared.py b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/shared.py index f5d50aac7a..eff05d5111 100644 --- a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/shared.py +++ b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/shared.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from fastapi import HTTPException, status from ibm_watsonx_orchestrate_clients.tools.tool_client import ClientAPIException from lfx.log.logger import logger -from lfx.services.adapters.deployment.exceptions import DeploymentConflictError, InvalidContentError +from lfx.services.adapters.deployment.exceptions import InvalidContentError, ResourceConflictError from langflow.services.adapters.deployment.watsonx_orchestrate.core.config import create_config, validate_connection from langflow.services.adapters.deployment.watsonx_orchestrate.core.retry import ( @@ -150,7 +150,7 @@ async def create_connection_with_conflict_mapping( "Use an existing connection by referencing its app_id in operations[*].app_ids, " "or choose a different app_id for connections.raw_payloads." ) - raise DeploymentConflictError(message=msg) from exc + raise ResourceConflictError(message=msg, resource="connection", resource_name=app_id) from exc raise diff --git a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/tools.py b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/tools.py index d9b2cc525f..b38d188dcd 100644 --- a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/tools.py +++ b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/core/tools.py @@ -13,12 +13,18 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any from cachetools import func +from fastapi import HTTPException +from ibm_watsonx_orchestrate_clients.tools.tool_client import ClientAPIException from ibm_watsonx_orchestrate_core.types.tools.langflow_tool import LangflowTool from ibm_watsonx_orchestrate_core.types.tools.langflow_tool import create_langflow_tool as _create_langflow_tool from lfx.log.logger import logger -from lfx.services.adapters.deployment.exceptions import InvalidContentError, InvalidDeploymentOperationError +from lfx.services.adapters.deployment.exceptions import ( + InvalidContentError, + InvalidDeploymentOperationError, +) from lfx.utils.flow_requirements import generate_requirements_from_flow +from langflow.services.adapters.deployment.watsonx_orchestrate.constants import ErrorPrefix from langflow.services.adapters.deployment.watsonx_orchestrate.core.retry import retry_create from langflow.services.adapters.deployment.watsonx_orchestrate.payloads import ( WatsonxFlowArtifactProviderData, @@ -27,6 +33,7 @@ from langflow.services.adapters.deployment.watsonx_orchestrate.payloads import ( from langflow.services.adapters.deployment.watsonx_orchestrate.utils import ( dedupe_list, normalize_wxo_name, + raise_as_deployment_error, require_tool_id, ) from langflow.utils.version import get_version_info @@ -417,7 +424,17 @@ async def upload_wxo_flow_tool( artifact_bytes: bytes, created_tool_ids_journal: list[str] | None = None, ) -> str: - tool_response = await retry_create(asyncio.to_thread, clients.tool.create, tool_payload) + tool_name = tool_payload.get("name") + try: + tool_response = await retry_create(asyncio.to_thread, clients.tool.create, tool_payload) + except (ClientAPIException, HTTPException) as exc: + raise_as_deployment_error( + exc, + error_prefix=ErrorPrefix.CREATE, + log_msg="Unexpected provider error during wxO tool create", + resource="tool", + resource_name=tool_name, + ) tool_id = require_tool_id(tool_response) logger.debug( "upload_wxo_flow_tool: created tool_id='%s', uploading artifact (%d bytes)", tool_id, len(artifact_bytes) @@ -425,13 +442,22 @@ async def upload_wxo_flow_tool( if created_tool_ids_journal is not None: created_tool_ids_journal.append(tool_id) - await retry_create( - asyncio.to_thread, - upload_tool_artifact_bytes, - clients, - tool_id=tool_id, - artifact_bytes=artifact_bytes, - ) + try: + await retry_create( + asyncio.to_thread, + upload_tool_artifact_bytes, + clients, + tool_id=tool_id, + artifact_bytes=artifact_bytes, + ) + except (ClientAPIException, HTTPException) as exc: + raise_as_deployment_error( + exc, + error_prefix=ErrorPrefix.CREATE, + log_msg="Unexpected provider error during wxO tool artifact upload", + resource="tool", + resource_name=tool_name, + ) return tool_id @@ -524,9 +550,6 @@ async def verify_tools_by_ids( """Fetch tools by ID and return only those that still exist on the provider.""" from lfx.services.adapters.deployment.schema import SnapshotItem, SnapshotListResult - from langflow.services.adapters.deployment.watsonx_orchestrate.constants import ErrorPrefix - from langflow.services.adapters.deployment.watsonx_orchestrate.utils import raise_as_deployment_error - if not snapshot_ids: return SnapshotListResult(snapshots=[]) diff --git a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/service.py b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/service.py index b9c6c680de..fbb223bda8 100644 --- a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/service.py +++ b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/service.py @@ -14,7 +14,6 @@ from lfx.services.adapters.deployment.exceptions import ( AuthenticationError, AuthorizationError, AuthSchemeError, - DeploymentConflictError, DeploymentError, DeploymentNotFoundError, DeploymentSupportError, @@ -22,8 +21,11 @@ from lfx.services.adapters.deployment.exceptions import ( InvalidDeploymentOperationError, InvalidDeploymentTypeError, OperationNotSupportedError, + ResourceConflictError, ResourceNotFoundError, - raise_for_status_and_detail, +) +from lfx.services.adapters.deployment.exceptions import ( + raise_as_deployment_error as raise_deployment_error_from_status, ) from lfx.services.adapters.deployment.schema import ( BaseDeploymentData, @@ -224,7 +226,7 @@ class WatsonxOrchestrateDeploymentService(BaseDeploymentService): ) except ( AuthenticationError, - DeploymentConflictError, + ResourceConflictError, InvalidContentError, InvalidDeploymentOperationError, InvalidDeploymentTypeError, @@ -500,7 +502,7 @@ class WatsonxOrchestrateDeploymentService(BaseDeploymentService): DeploymentNotFoundError, InvalidContentError, InvalidDeploymentOperationError, - DeploymentConflictError, + ResourceConflictError, ): raise except Exception as exc: @@ -868,7 +870,7 @@ class WatsonxOrchestrateDeploymentService(BaseDeploymentService): "Credential verification failed (status=%s)", exc.status_code, ) - raise_for_status_and_detail( + raise_deployment_error_from_status( status_code=exc.status_code, detail="Credential verification failed.", message_prefix="Credential verification", diff --git a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/utils.py b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/utils.py index 212cff995e..924a329893 100644 --- a/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/utils.py +++ b/src/backend/base/langflow/services/adapters/deployment/watsonx_orchestrate/utils.py @@ -13,7 +13,9 @@ from lfx.services.adapters.deployment.exceptions import ( DeploymentServiceError, InvalidContentError, OperationNotSupportedError, - raise_for_status_and_detail, +) +from lfx.services.adapters.deployment.exceptions import ( + raise_as_deployment_error as raise_deployment_error_from_status, ) from lfx.services.adapters.deployment.schema import _normalize_and_validate_id @@ -126,9 +128,9 @@ def _resolve_exc_detail(exc: ClientAPIException | HTTPException) -> str: return str(extract_error_detail(str(exc.detail))) -def _resolve_exc_status_code(exc: ClientAPIException | HTTPException) -> int | None: +def _resolve_exc_status_code(exc: ClientAPIException | HTTPException) -> int: if isinstance(exc, ClientAPIException): - return int(getattr(exc.response, "status_code", 0) or 0) or None + return int(exc.response.status_code) return int(exc.status_code) @@ -137,6 +139,8 @@ def raise_as_deployment_error( *, error_prefix: ErrorPrefix, log_msg: str, + resource: str | None = None, + resource_name: str | None = None, pass_through: tuple[type[DeploymentServiceError], ...] = (), ) -> NoReturn: if isinstance(exc, pass_through): @@ -148,10 +152,12 @@ def raise_as_deployment_error( if isinstance(exc, (ClientAPIException, HTTPException)): status_code = _resolve_exc_status_code(exc) detail = _resolve_exc_detail(exc) - raise_for_status_and_detail( + raise_deployment_error_from_status( status_code=status_code, detail=detail, message_prefix=error_prefix.value, + resource=resource, + resource_name=resource_name, cause=exc, ) logger.exception(log_msg) diff --git a/src/backend/tests/unit/api/v1/test_deployment_mapper_base.py b/src/backend/tests/unit/api/v1/test_deployment_mapper_base.py index d482745646..8b2b2a80b1 100644 --- a/src/backend/tests/unit/api/v1/test_deployment_mapper_base.py +++ b/src/backend/tests/unit/api/v1/test_deployment_mapper_base.py @@ -584,7 +584,10 @@ def test_base_mapper_formats_conflict_detail_with_generic_fallback() -> None: detail = mapper.format_conflict_detail("provider conflict detail") - assert detail == "A resource with this name already exists in the provider. provider conflict detail" + assert ( + detail + == "A resource conflict occurred in the deployment provider. The requested operation could not be completed." + ) def test_base_mapper_shapes_deployment_update_result() -> None: diff --git a/src/backend/tests/unit/api/v1/test_deployment_mapper_watsonx.py b/src/backend/tests/unit/api/v1/test_deployment_mapper_watsonx.py index ef2f608e0b..833eb1f655 100644 --- a/src/backend/tests/unit/api/v1/test_deployment_mapper_watsonx.py +++ b/src/backend/tests/unit/api/v1/test_deployment_mapper_watsonx.py @@ -195,31 +195,19 @@ def test_watsonx_mapper_deployment_list_result_rejects_unknown_flattened_entry_f @pytest.mark.parametrize( ("raw_message", "expected"), [ - ( - "Agent already exists in provider", - "An agent with this name already exists in the provider. " - "Please choose a different name or delete the existing agent first.", - ), - ( - "connection conflict for app_id xyz", - "A connection referenced in this request already exists in the provider. " - "Reference it as an existing connection instead of creating a new one.", - ), - ( - "tool already exists", - "A tool with this name already exists in the provider. Please choose a different name.", - ), ( "app_id is required", - "A resource with this name already exists in the provider. app_id is required", + "A resource conflict occurred in the deployment provider. The requested operation could not be completed.", ), ( "unexpected conflict", - "A resource with this name already exists in the provider. unexpected conflict", + "A resource conflict occurred in the deployment provider. The requested operation could not be completed.", ), ], ) -def test_watsonx_mapper_formats_conflict_detail(raw_message: str, expected: str) -> None: +def test_watsonx_mapper_formats_conflict_detail_fallback_without_structured_entity( + raw_message: str, expected: str +) -> None: mapper = WatsonxOrchestrateDeploymentMapper() detail = mapper.format_conflict_detail(raw_message) @@ -227,6 +215,61 @@ def test_watsonx_mapper_formats_conflict_detail(raw_message: str, expected: str) assert detail == expected +@pytest.mark.parametrize( + ("resource", "expected"), + [ + ("tool", "A tool with this name already exists in the provider. Please choose a different name."), + ( + "connection", + "A connection referenced in this request already exists in the provider. " + "Reference it as an existing connection instead of creating a new one.", + ), + ( + "agent", + "An agent with this name already exists in the provider. " + "Please choose a different name or delete the existing agent first.", + ), + ], +) +def test_watsonx_mapper_formats_conflict_detail_from_structured_resource(resource: str, expected: str) -> None: + mapper = WatsonxOrchestrateDeploymentMapper() + assert mapper.format_conflict_detail("provider conflict payload", resource=resource) == expected + + +@pytest.mark.parametrize( + ("resource", "resource_name", "expected"), + [ + ( + "tool", + "Simple_Agent", + "A tool with name 'Simple_Agent' already exists in the provider. Please choose a different name.", + ), + ( + "connection", + "cfg", + "A connection with app_id 'cfg' already exists in the provider. " + "Reference it as an existing connection instead of creating a new one.", + ), + ( + "agent", + "My_Agent", + "An agent with name 'My_Agent' already exists in the provider. " + "Please choose a different name or delete the existing agent first.", + ), + ], +) +def test_watsonx_mapper_formats_conflict_detail_from_structured_resource_and_name( + resource: str, resource_name: str, expected: str +) -> None: + mapper = WatsonxOrchestrateDeploymentMapper() + detail = mapper.format_conflict_detail( + "provider conflict payload", + resource=resource, + resource_name=resource_name, + ) + assert detail == expected + + def test_watsonx_mapper_flow_version_item_data_from_snapshot_connections() -> None: mapper = WatsonxOrchestrateDeploymentMapper() snapshot_result = SnapshotListResult( diff --git a/src/backend/tests/unit/api/v1/test_deployment_route_handlers.py b/src/backend/tests/unit/api/v1/test_deployment_route_handlers.py index ebaaed6ab0..d67c4704d4 100644 --- a/src/backend/tests/unit/api/v1/test_deployment_route_handlers.py +++ b/src/backend/tests/unit/api/v1/test_deployment_route_handlers.py @@ -25,8 +25,8 @@ from langflow.api.v1.schemas.deployments import ( from langflow.services.database.models.deployment_provider_account.schemas import DeploymentProviderKey from lfx.services.adapters.deployment.exceptions import ( AuthenticationError, - DeploymentConflictError, DeploymentNotFoundError, + ResourceConflictError, ServiceUnavailableError, ) from lfx.services.adapters.deployment.schema import ( @@ -2404,17 +2404,59 @@ class TestHandleAdapterErrors: deployment_mapper.format_conflict_detail.return_value = "friendly detail" with pytest.raises(HTTPException) as exc_info, handle_adapter_errors(mapper=deployment_mapper): - raise DeploymentConflictError(message="raw provider conflict") + raise ResourceConflictError(message="raw provider conflict") assert exc_info.value.status_code == 409 assert exc_info.value.detail == "friendly detail" - deployment_mapper.format_conflict_detail.assert_called_once_with("raw provider conflict") + deployment_mapper.format_conflict_detail.assert_called_once_with( + "raw provider conflict", + resource=None, + resource_name=None, + ) + + def test_maps_conflict_passes_structured_resource_to_mapper_formatter(self): + from langflow.api.v1.mappers.deployments.helpers import handle_adapter_errors + + deployment_mapper = MagicMock() + deployment_mapper.format_conflict_detail.return_value = "friendly detail" + + with pytest.raises(HTTPException) as exc_info, handle_adapter_errors(mapper=deployment_mapper): + raise ResourceConflictError(message="raw provider conflict", resource="tool") + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == "friendly detail" + deployment_mapper.format_conflict_detail.assert_called_once_with( + "raw provider conflict", + resource="tool", + resource_name=None, + ) + + def test_maps_conflict_passes_structured_resource_name_to_mapper_formatter(self): + from langflow.api.v1.mappers.deployments.helpers import handle_adapter_errors + + deployment_mapper = MagicMock() + deployment_mapper.format_conflict_detail.return_value = "friendly detail" + + with pytest.raises(HTTPException) as exc_info, handle_adapter_errors(mapper=deployment_mapper): + raise ResourceConflictError( + message="raw provider conflict", + resource="tool", + resource_name="Simple_Agent", + ) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail == "friendly detail" + deployment_mapper.format_conflict_detail.assert_called_once_with( + "raw provider conflict", + resource="tool", + resource_name="Simple_Agent", + ) def test_maps_conflict_without_mapper_passthrough(self): from langflow.api.v1.mappers.deployments.helpers import handle_adapter_errors with pytest.raises(HTTPException) as exc_info, handle_adapter_errors(): - raise DeploymentConflictError(message="raw provider conflict") + raise ResourceConflictError(message="raw provider conflict") assert exc_info.value.status_code == 409 assert exc_info.value.detail == "raw provider conflict" diff --git a/src/backend/tests/unit/services/deployment/test_watsonx_orchestrate.py b/src/backend/tests/unit/services/deployment/test_watsonx_orchestrate.py index 9f1bbaf062..0dbab60d32 100644 --- a/src/backend/tests/unit/services/deployment/test_watsonx_orchestrate.py +++ b/src/backend/tests/unit/services/deployment/test_watsonx_orchestrate.py @@ -12,13 +12,13 @@ from fastapi import HTTPException, status from lfx.services.adapters.deployment.exceptions import ( AuthorizationError, CredentialResolutionError, - DeploymentConflictError, DeploymentError, DeploymentNotFoundError, DeploymentSupportError, InvalidContentError, InvalidDeploymentOperationError, OperationNotSupportedError, + ResourceConflictError, ResourceNotFoundError, ) from lfx.services.adapters.deployment.schema import ( @@ -2015,7 +2015,7 @@ async def test_update_provider_data_maps_raw_connection_conflict_to_deployment_c monkeypatch.setattr(service, "_get_provider_clients", mock_get_provider_clients) monkeypatch.setattr(shared_core_module, "create_config", mock_create_config) - with pytest.raises(DeploymentConflictError, match="already exists in the provider"): + with pytest.raises(ResourceConflictError, match="already exists in the provider") as exc_info: await service.update( user_id="user-1", deployment_id="dep-1", @@ -2053,6 +2053,7 @@ async def test_update_provider_data_maps_raw_connection_conflict_to_deployment_c ), db=object(), ) + assert exc_info.value.resource == "connection" @pytest.mark.anyio @@ -2078,7 +2079,7 @@ async def test_create_provider_data_maps_raw_connection_conflict_to_deployment_c monkeypatch.setattr(service, "_get_provider_clients", mock_get_provider_clients) monkeypatch.setattr(shared_core_module, "create_config", mock_create_config) - with pytest.raises(DeploymentConflictError, match="already exists in the provider"): + with pytest.raises(ResourceConflictError, match="already exists in the provider") as exc_info: await service.create( user_id="user-1", payload=DeploymentCreate( @@ -2120,6 +2121,7 @@ async def test_create_provider_data_maps_raw_connection_conflict_to_deployment_c ), db=object(), ) + assert exc_info.value.resource == "connection" assert captured["attempted_app_id"] == "cfg" @@ -4453,7 +4455,7 @@ def test_is_retryable_create_exception_retryable_status_codes(): def test_is_retryable_create_exception_domain_exceptions_not_retryable(): from langflow.services.adapters.deployment.watsonx_orchestrate.core.retry import is_retryable_create_exception - assert is_retryable_create_exception(DeploymentConflictError()) is False + assert is_retryable_create_exception(ResourceConflictError()) is False assert is_retryable_create_exception(InvalidContentError()) is False assert is_retryable_create_exception(InvalidDeploymentOperationError()) is False @@ -4830,6 +4832,40 @@ async def test_list_deployments_without_params(monkeypatch): assert result.deployments[0].id == "dep-1" +@pytest.mark.anyio +async def test_list_conflict_does_not_force_agent_resource_hint(monkeypatch): + from ibm_watsonx_orchestrate_clients.tools.tool_client import ClientAPIException + + service = WatsonxOrchestrateDeploymentService(DummySettingsService()) + + class FailingListAgentClient(FakeAgentClient): + def _get(self, path: str, params: dict | None = None): # noqa: ARG002 + if path == "/agents": + raise ClientAPIException( + response=SimpleNamespace(status_code=409, text='{"detail":"resource already exists"}') + ) + return {} + + fake_agent = FailingListAgentClient({"id": "dep-1", "tools": []}) + fake_clients = _with_wxo_wrappers( + SimpleNamespace( + _base=fake_agent, + agent=fake_agent, + ) + ) + + async def mock_get_provider_clients(*, user_id, db): # noqa: ARG001 + return fake_clients + + monkeypatch.setattr(service, "_get_provider_clients", mock_get_provider_clients) + + with pytest.raises(ResourceConflictError) as exc_info: + await service.list(user_id="user-1", db=object(), params=None) + + assert exc_info.value.resource is None + assert exc_info.value.resource_name is None + + @pytest.mark.anyio async def test_list_types_returns_supported_types(): service = WatsonxOrchestrateDeploymentService(DummySettingsService()) @@ -5268,6 +5304,63 @@ async def test_create_and_upload_wxo_flow_tools_with_bindings_journals_created_i assert len(created_calls) == 2 +@pytest.mark.anyio +async def test_upload_wxo_flow_tool_maps_tool_conflict_with_structured_resource(): + from ibm_watsonx_orchestrate_clients.tools.tool_client import ClientAPIException + + def mock_create_tool(payload: dict): # noqa: ARG001 + raise ClientAPIException( + response=SimpleNamespace( + status_code=409, + text='{"detail":"Tool with name \'Simple_Agent\' already exists for this tenant."}', + ) + ) + + fake_clients = SimpleNamespace( + tool=SimpleNamespace(create=mock_create_tool), + upload_tool_artifact=lambda tool_id, files: {"id": tool_id}, # noqa: ARG005 + ) + + with pytest.raises(ResourceConflictError) as exc_info: + await tools_module.upload_wxo_flow_tool( + clients=fake_clients, + tool_payload={"name": "Simple_Agent"}, + artifact_bytes=b"artifact", + ) + + assert exc_info.value.resource == "tool" + assert exc_info.value.resource_name == "Simple_Agent" + + +@pytest.mark.anyio +async def test_create_agent_deployment_maps_agent_conflict_with_structured_resource(): + from ibm_watsonx_orchestrate_clients.tools.tool_client import ClientAPIException + + def mock_create_agent(payload: dict): # noqa: ARG001 + raise ClientAPIException( + response=SimpleNamespace( + status_code=409, + text='{"detail":"Agent with name \'my_agent\' already exists for this tenant."}', + ) + ) + + fake_clients = SimpleNamespace(agent=SimpleNamespace(create=mock_create_agent)) + + with pytest.raises(ResourceConflictError) as exc_info: + await create_core_module.create_agent_deployment( + clients=fake_clients, + tool_ids=["tool-1"], + agent_name="my_agent", + agent_display_name="My Agent", + deployment_name="my deployment", + description="desc", + llm=TEST_WXO_LLM, + ) + + assert exc_info.value.resource == "agent" + assert exc_info.value.resource_name == "my_agent" + + def test_extract_error_detail_json_string(): from langflow.services.adapters.deployment.watsonx_orchestrate.utils import extract_error_detail @@ -5396,11 +5489,13 @@ def test_raise_as_deployment_error_maps_conflict(): resp = SimpleNamespace(status_code=500, text='{"detail":"resource already exists"}') exc = ClientAPIException(response=resp) - with pytest.raises(DeploymentConflictError, match="already exists"): + with pytest.raises(ResourceConflictError, match="already exists"): raise_as_deployment_error( exc, error_prefix=ErrorPrefix.UPDATE, log_msg="unexpected update conflict", + resource="agent", + resource_name="my-agent", ) @@ -5791,9 +5886,9 @@ def test_retry_rollback_uses_retryable_filter(): ) # Domain exceptions that are non-retryable - from lfx.services.adapters.deployment.exceptions import DeploymentConflictError, InvalidContentError + from lfx.services.adapters.deployment.exceptions import InvalidContentError, ResourceConflictError - assert not is_retryable_create_exception(DeploymentConflictError()) + assert not is_retryable_create_exception(ResourceConflictError()) assert not is_retryable_create_exception(InvalidContentError()) # Generic exceptions are retryable (e.g. transient network errors) @@ -5897,21 +5992,21 @@ def test_raise_for_status_separates_status_codes_from_string_heuristics(): Now, the 404 status code check is standalone, and 'not found' string heuristic only fires as a fallback for unmapped status codes. """ - from lfx.services.adapters.deployment.exceptions import ResourceNotFoundError, raise_for_status_and_detail + from lfx.services.adapters.deployment.exceptions import ResourceNotFoundError, raise_as_deployment_error # status_code=404 raises ResourceNotFoundError regardless of detail text with pytest.raises(ResourceNotFoundError): - raise_for_status_and_detail(status_code=404, detail="anything", message_prefix="test") + raise_as_deployment_error(status_code=404, detail="anything", message_prefix="test") - # status_code=409 raises DeploymentConflictError regardless of detail text - with pytest.raises(DeploymentConflictError): - raise_for_status_and_detail(status_code=409, detail="anything", message_prefix="test") + # status_code=409 raises ResourceConflictError regardless of detail text + with pytest.raises(ResourceConflictError): + raise_as_deployment_error(status_code=409, detail="anything", message_prefix="test") # String heuristics still work as fallback for unmapped/None status codes with pytest.raises(ResourceNotFoundError): - raise_for_status_and_detail(status_code=None, detail="agent not found", message_prefix="test") - with pytest.raises(DeploymentConflictError): - raise_for_status_and_detail(status_code=None, detail="resource already exists", message_prefix="test") + raise_as_deployment_error(status_code=None, detail="agent not found", message_prefix="test") + with pytest.raises(ResourceConflictError): + raise_as_deployment_error(status_code=None, detail="resource already exists", message_prefix="test") # --------------------------------------------------------------------------- @@ -5921,7 +6016,7 @@ def test_raise_for_status_separates_status_codes_from_string_heuristics(): @pytest.mark.anyio async def test_create_maps_409_conflict_to_deployment_conflict_error(): - """Create raises DeploymentConflictError when the provider agent create returns a 409.""" + """Create raises ResourceConflictError when the provider agent create returns a 409.""" from ibm_watsonx_orchestrate_clients.tools.tool_client import ClientAPIException service = WatsonxOrchestrateDeploymentService(DummySettingsService()) @@ -5937,7 +6032,7 @@ async def test_create_maps_409_conflict_to_deployment_conflict_error(): ) _attach_provider_clients(service, clients) - with pytest.raises(DeploymentConflictError, match="already exist"): + with pytest.raises(ResourceConflictError, match="already exist"): await service.create( user_id="user-1", db=object(), @@ -5952,6 +6047,51 @@ async def test_create_maps_409_conflict_to_deployment_conflict_error(): ) +@pytest.mark.anyio +async def test_create_tool_conflict_detail_is_not_misclassified_as_agent(monkeypatch): + """Service-layer catch does not carry resource hints — resource stays None. + + This proves the conflict is not misclassified as 'agent'. The inner-layer test + ``test_upload_wxo_flow_tool_maps_tool_conflict_with_structured_resource`` + covers the path where ``resource='tool'`` is correctly attached. + """ + from ibm_watsonx_orchestrate_clients.tools.tool_client import ClientAPIException + + service = WatsonxOrchestrateDeploymentService(DummySettingsService()) + _attach_provider_clients(service, FakeWXOClients()) + + async def mock_apply_provider_create_plan_with_rollback(**kwargs): # noqa: ARG001 + raise ClientAPIException( + response=SimpleNamespace( + status_code=409, + text='{"detail":"Tool with name \'Simple_Agent\' already exists for this tenant."}', + ) + ) + + monkeypatch.setattr( + service_module, + "apply_provider_create_plan_with_rollback", + mock_apply_provider_create_plan_with_rollback, + ) + + with pytest.raises(ResourceConflictError) as exc_info: + await service.create( + user_id="user-1", + db=object(), + payload=DeploymentCreate( + spec=BaseDeploymentData( + name="ahhahahaqwerg", + description="desc", + type=DeploymentType.AGENT, + ), + provider_data=_create_provider_spec(), + ), + ) + + assert exc_info.value.resource is None + assert exc_info.value.resource_name is None + + @pytest.mark.anyio async def test_create_maps_422_to_invalid_content_error(): """Create raises InvalidContentError when the provider agent create returns a 422.""" @@ -6265,7 +6405,9 @@ async def test_create_preserves_exception_chain_on_unexpected_error(): ), ) - assert exc_info.value.__cause__ is original_error + inner = exc_info.value.__cause__ + assert isinstance(inner, DeploymentError) + assert inner.__cause__ is original_error @pytest.mark.anyio diff --git a/src/lfx/src/lfx/services/adapters/deployment/exceptions.py b/src/lfx/src/lfx/services/adapters/deployment/exceptions.py index ab8f2e59ca..36f19c2c9c 100644 --- a/src/lfx/src/lfx/services/adapters/deployment/exceptions.py +++ b/src/lfx/src/lfx/services/adapters/deployment/exceptions.py @@ -68,10 +68,19 @@ class CredentialResolutionError(AuthenticationError): super().__init__(message, error_code="credentials_resolution_error", cause=cause) -class DeploymentConflictError(DeploymentError): +class ResourceConflictError(DeploymentError): """Raised when a deployment conflict occurs.""" - def __init__(self, message: str = "Deployment conflict occurred", *, cause: Exception | None = None): + def __init__( + self, + message: str = "Deployment conflict occurred", + *, + resource: str | None = None, + resource_name: str | None = None, + cause: Exception | None = None, + ): + self.resource = str(resource).strip().lower() if resource is not None else None + self.resource_name = str(resource_name).strip() or None if resource_name is not None else None super().__init__(message, error_code="deployment_conflict", cause=cause) @@ -221,14 +230,14 @@ class DeploymentNotConfiguredError(DeploymentError): def http_status_for_deployment_error(exc: DeploymentServiceError) -> int: """Return the HTTP status code that best represents a domain exception. - This is the inverse of :func:`raise_for_status_and_detail`: given a + This is the inverse of :func:`raise_as_deployment_error`: given a domain exception instance, it returns the HTTP status code that an API layer should use when surfacing the error to a client. Order mirrors the except-chain priority in the Langflow route layer: more specific exception types are checked before their parents. """ - if isinstance(exc, DeploymentConflictError): + if isinstance(exc, ResourceConflictError): return status.HTTP_409_CONFLICT if isinstance(exc, InvalidDeploymentOperationError): return status.HTTP_400_BAD_REQUEST @@ -262,11 +271,13 @@ def http_status_for_deployment_error(exc: DeploymentServiceError) -> int: # lru+ttl cache? -def raise_for_status_and_detail( +def raise_as_deployment_error( *, status_code: int | None, detail: str, message_prefix: str | None = None, + resource: str | None = None, + resource_name: str | None = None, cause: Exception | None = None, ) -> NoReturn: """Raise domain-specific deployment exceptions based on HTTP-like status/detail. @@ -276,6 +287,10 @@ def raise_for_status_and_detail( *cause* to preserve the traceback chain for debugging. Callers that handle security-sensitive errors (e.g. credential verification) should set it to None to avoid leaking provider responses through the exception chain. + + For conflict errors, callers should pass ``resource`` and ``resource_name`` + whenever known. This helper does not infer conflict hints from free-form + provider detail text. """ detail_text = str(detail) detail_lower = detail_text.lower() @@ -301,7 +316,12 @@ def raise_for_status_and_detail( if status_code == status.HTTP_410_GONE: raise ResourceNotFoundError(message, cause=cause) from cause if status_code == status.HTTP_409_CONFLICT: - raise DeploymentConflictError(message=message, cause=cause) from cause + raise ResourceConflictError( + message=message, + resource=resource, + resource_name=resource_name, + cause=cause, + ) from cause if status_code == status.HTTP_429_TOO_MANY_REQUESTS: raise RateLimitError(message=message, cause=cause) from cause if status_code in {status.HTTP_408_REQUEST_TIMEOUT, status.HTTP_504_GATEWAY_TIMEOUT}: @@ -311,7 +331,12 @@ def raise_for_status_and_detail( if "not found" in detail_lower: raise ResourceNotFoundError(message, cause=cause) from cause if "already exists" in detail_lower or "conflict" in detail_lower: - raise DeploymentConflictError(message=message, cause=cause) from cause + raise ResourceConflictError( + message=message, + resource=resource, + resource_name=resource_name, + cause=cause, + ) from cause if "unprocessable" in detail_lower: raise InvalidContentError(message=message, cause=cause) from cause if "too many requests" in detail_lower or "rate limit" in detail_lower: diff --git a/src/lfx/tests/unit/services/deployment/test_deployment_exceptions.py b/src/lfx/tests/unit/services/deployment/test_deployment_exceptions.py index ba05736b1a..23cfcb41a3 100644 --- a/src/lfx/tests/unit/services/deployment/test_deployment_exceptions.py +++ b/src/lfx/tests/unit/services/deployment/test_deployment_exceptions.py @@ -13,7 +13,6 @@ from lfx.services.adapters.deployment.exceptions import ( AuthorizationError, AuthSchemeError, CredentialResolutionError, - DeploymentConflictError, DeploymentNotFoundError, DeploymentSupportError, DeploymentTimeoutError, @@ -22,10 +21,11 @@ from lfx.services.adapters.deployment.exceptions import ( InvalidDeploymentTypeError, OperationNotSupportedError, RateLimitError, + ResourceConflictError, ResourceNotFoundError, ServiceUnavailableError, http_status_for_deployment_error, - raise_for_status_and_detail, + raise_as_deployment_error, ) from lfx.services.interfaces import DeploymentServiceProtocol @@ -47,7 +47,7 @@ def test_exception_hierarchy_is_preserved() -> None: assert issubclass(AuthSchemeError, AuthenticationError) # Deployment operation subtypes - assert issubclass(DeploymentConflictError, DeploymentError) + assert issubclass(ResourceConflictError, DeploymentError) assert issubclass(InvalidContentError, DeploymentError) assert issubclass(InvalidDeploymentOperationError, DeploymentError) assert issubclass(ResourceNotFoundError, DeploymentError) @@ -59,7 +59,7 @@ def test_exception_hierarchy_is_preserved() -> None: def test_exception_error_codes_are_set() -> None: assert AuthorizationError("forbidden", error_code="authorization_error").error_code == "authorization_error" assert CredentialResolutionError().error_code == "credentials_resolution_error" - assert DeploymentConflictError().error_code == "deployment_conflict" + assert ResourceConflictError().error_code == "deployment_conflict" assert RateLimitError().error_code == "deployment_rate_limited" assert DeploymentTimeoutError().error_code == "deployment_timeout" assert ServiceUnavailableError().error_code == "deployment_provider_unavailable" @@ -73,6 +73,12 @@ def test_exception_error_codes_are_set() -> None: assert OperationNotSupportedError().error_code == "operation_not_supported" +def test_deployment_conflict_error_resource_is_optional_and_normalized() -> None: + err = ResourceConflictError(resource=" TOOL ") + assert err.resource == "tool" + assert ResourceConflictError().resource is None + + def test_deployment_type_exceptions_have_distinct_default_messages() -> None: assert str(DeploymentSupportError()) == "Deployment type is unsupported by this adapter" assert str(InvalidDeploymentTypeError()) == "Deployment type is malformed or unknown" @@ -206,71 +212,95 @@ def test_deployment_service_error_catches_both_hierarchies() -> None: raise DeploymentNotFoundError -def test_raise_for_status_and_detail_maps_known_http_statuses() -> None: +def test_raise_as_deployment_error_maps_known_http_statuses() -> None: with pytest.raises(AuthenticationError): - raise_for_status_and_detail(status_code=401, detail="unauthorized", message_prefix="x") + raise_as_deployment_error(status_code=401, detail="unauthorized", message_prefix="x") with pytest.raises(AuthorizationError): - raise_for_status_and_detail(status_code=403, detail="forbidden", message_prefix="x") + raise_as_deployment_error(status_code=403, detail="forbidden", message_prefix="x") with pytest.raises(ResourceNotFoundError): - raise_for_status_and_detail(status_code=404, detail="missing", message_prefix="x") - with pytest.raises(DeploymentConflictError): - raise_for_status_and_detail(status_code=409, detail="conflict", message_prefix="x") + raise_as_deployment_error(status_code=404, detail="missing", message_prefix="x") + with pytest.raises(ResourceConflictError): + raise_as_deployment_error(status_code=409, detail="conflict", message_prefix="x") with pytest.raises(InvalidContentError): - raise_for_status_and_detail(status_code=422, detail="unprocessable", message_prefix="x") + raise_as_deployment_error(status_code=422, detail="unprocessable", message_prefix="x") with pytest.raises(InvalidDeploymentOperationError): - raise_for_status_and_detail(status_code=400, detail="bad request", message_prefix="x") + raise_as_deployment_error(status_code=400, detail="bad request", message_prefix="x") with pytest.raises(InvalidDeploymentOperationError): - raise_for_status_and_detail(status_code=405, detail="method not allowed", message_prefix="x") + raise_as_deployment_error(status_code=405, detail="method not allowed", message_prefix="x") with pytest.raises(InvalidContentError): - raise_for_status_and_detail(status_code=413, detail="payload too large", message_prefix="x") + raise_as_deployment_error(status_code=413, detail="payload too large", message_prefix="x") with pytest.raises(InvalidContentError): - raise_for_status_and_detail(status_code=415, detail="unsupported media type", message_prefix="x") + raise_as_deployment_error(status_code=415, detail="unsupported media type", message_prefix="x") with pytest.raises(ResourceNotFoundError): - raise_for_status_and_detail(status_code=410, detail="gone", message_prefix="x") + raise_as_deployment_error(status_code=410, detail="gone", message_prefix="x") with pytest.raises(RateLimitError): - raise_for_status_and_detail(status_code=429, detail="too many requests", message_prefix="x") + raise_as_deployment_error(status_code=429, detail="too many requests", message_prefix="x") with pytest.raises(DeploymentTimeoutError): - raise_for_status_and_detail(status_code=408, detail="timeout", message_prefix="x") + raise_as_deployment_error(status_code=408, detail="timeout", message_prefix="x") with pytest.raises(DeploymentTimeoutError): - raise_for_status_and_detail(status_code=504, detail="gateway timeout", message_prefix="x") + raise_as_deployment_error(status_code=504, detail="gateway timeout", message_prefix="x") with pytest.raises(ServiceUnavailableError): - raise_for_status_and_detail(status_code=502, detail="bad gateway", message_prefix="x") + raise_as_deployment_error(status_code=502, detail="bad gateway", message_prefix="x") with pytest.raises(ServiceUnavailableError): - raise_for_status_and_detail(status_code=503, detail="service unavailable", message_prefix="x") + raise_as_deployment_error(status_code=503, detail="service unavailable", message_prefix="x") -def test_raise_for_status_and_detail_uses_detail_heuristics_without_status() -> None: +def test_raise_as_deployment_error_does_not_infer_conflict_hints_from_detail() -> None: + with pytest.raises(ResourceConflictError) as exc_info: + raise_as_deployment_error( + status_code=409, + detail="Tool with name 'Simple_Agent' already exists for this tenant.", + message_prefix="x", + ) + assert exc_info.value.resource is None + assert exc_info.value.resource_name is None + + +def test_raise_as_deployment_error_uses_explicit_conflict_hints() -> None: + with pytest.raises(ResourceConflictError) as exc_info: + raise_as_deployment_error( + status_code=409, + detail="already exists", + message_prefix="x", + resource="TOOL", + resource_name=" Simple_Agent ", + ) + assert exc_info.value.resource == "tool" + assert exc_info.value.resource_name == "Simple_Agent" + + +def test_raise_as_deployment_error_uses_detail_heuristics_without_status() -> None: with pytest.raises(AuthorizationError): - raise_for_status_and_detail(status_code=None, detail="permission denied") + raise_as_deployment_error(status_code=None, detail="permission denied") with pytest.raises(InvalidContentError): - raise_for_status_and_detail(status_code=None, detail="invalid payload") + raise_as_deployment_error(status_code=None, detail="invalid payload") with pytest.raises(RateLimitError): - raise_for_status_and_detail(status_code=None, detail="rate limit exceeded") + raise_as_deployment_error(status_code=None, detail="rate limit exceeded") with pytest.raises(DeploymentTimeoutError): - raise_for_status_and_detail(status_code=None, detail="request timed out") + raise_as_deployment_error(status_code=None, detail="request timed out") with pytest.raises(ServiceUnavailableError): - raise_for_status_and_detail(status_code=None, detail="service unavailable") + raise_as_deployment_error(status_code=None, detail="service unavailable") -def test_raise_for_status_and_detail_chains_cause_when_provided() -> None: +def test_raise_as_deployment_error_chains_cause_when_provided() -> None: """When cause is passed, the raised exception preserves the chain.""" original = RuntimeError("upstream broke") with pytest.raises(ResourceNotFoundError) as exc_info: - raise_for_status_and_detail(status_code=404, detail="gone", cause=original) + raise_as_deployment_error(status_code=404, detail="gone", cause=original) assert exc_info.value.__cause__ is original assert exc_info.value.cause is original def _raise_for_status_inside_except_handler() -> None: - """Helper: call raise_for_status_and_detail while an exception is active.""" + """Helper: call raise_as_deployment_error while an exception is active.""" try: msg = "should be suppressed" raise RuntimeError(msg) except RuntimeError: - raise_for_status_and_detail(status_code=404, detail="gone") + raise_as_deployment_error(status_code=404, detail="gone") -def test_raise_for_status_and_detail_suppresses_context_without_cause() -> None: +def test_raise_as_deployment_error_suppresses_context_without_cause() -> None: """Default (no cause) suppresses implicit exception context.""" with pytest.raises(ResourceNotFoundError) as exc_info: _raise_for_status_inside_except_handler() @@ -281,7 +311,7 @@ def test_raise_for_status_and_detail_suppresses_context_without_cause() -> None: @pytest.mark.parametrize( ("exc", "expected_status"), [ - (DeploymentConflictError(), 409), + (ResourceConflictError(), 409), (InvalidDeploymentOperationError(), 400), (DeploymentSupportError(), 400), (InvalidDeploymentTypeError(), 400),