mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 18:57:09 +08:00
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
This commit is contained in:
@ -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":
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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=[])
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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),
|
||||
|
||||
Reference in New Issue
Block a user