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:
Hamza Rashid
2026-04-13 12:08:40 -04:00
committed by GitHub
parent 5cb8567130
commit 349e78c758
16 changed files with 505 additions and 140 deletions

View File

@ -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":

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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(

View File

@ -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,

View File

@ -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

View File

@ -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=[])

View File

@ -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",

View File

@ -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)

View File

@ -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:

View File

@ -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(

View File

@ -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"

View File

@ -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

View File

@ -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:

View File

@ -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),