From 3fe52b7670bf5a8287e35495b66fea342151d519 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:32:27 +0000 Subject: [PATCH 1/3] fix(cloud): raise AirbyteCloudError on malformed skill docs responses Co-Authored-By: AJ Steers --- airbyte/_direct_connectors/api_util.py | 22 +++++++++++--- airbyte/cloud/connectors.py | 34 +++++++++------------- airbyte/cloud/workspaces.py | 12 ++++---- tests/unit_tests/test_cloud_credentials.py | 24 +++++++++++++++ tests/unit_tests/test_cloud_describe.py | 14 ++++++--- tests/unit_tests/test_cloud_guidance.py | 8 ++--- 6 files changed, 75 insertions(+), 39 deletions(-) diff --git a/airbyte/_direct_connectors/api_util.py b/airbyte/_direct_connectors/api_util.py index f999fca03..520d2d89d 100644 --- a/airbyte/_direct_connectors/api_util.py +++ b/airbyte/_direct_connectors/api_util.py @@ -18,6 +18,7 @@ from airbyte._direct_connectors.models import ( ConnectorEnablement, + DirectAccessGuidance, ExternalApiExecuteResult, ExternalSearchResult, ExternalSearchStatusResult, @@ -350,21 +351,34 @@ def read_cloud_skill_docs( skill_id: str, credentials: _AirbyteCredentials, section: str | None = None, -) -> dict[str, Any]: +) -> DirectAccessGuidance: """Read skill docs for a workspace via the Cloud Config API. The `skill_id` is sent as the `id` query parameter, with `section` added only when - provided. + provided. A response that does not match `DirectAccessGuidance` raises + `AirbyteCloudError`. """ params: dict[str, Any] = {"id": skill_id} if section is not None: params["section"] = section - return make_cloud_agent_request( + path = f"/workspaces/{workspace_id}/skills/docs" + response = make_cloud_agent_request( method="GET", - path=f"/workspaces/{workspace_id}/skills/docs", + path=path, credentials=credentials, params=params, ) + try: + return DirectAccessGuidance.model_validate(response) + except ValidationError as ex: + raise AirbyteCloudError( + message="Malformed Airbyte Cloud skill docs response.", + context={ + "path": path, + "skill_id": skill_id, + "errors": ex.errors(include_url=False), + }, + ) from ex class _ConnectorLookup(NamedTuple): diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index b2fcf54b4..671f3dbe9 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -759,12 +759,10 @@ def _context_layer_inspect( else connector_docs.destination_skill_id(self.connector_id) ) try: - docs = DirectAccessGuidance.model_validate( - agents_api_util.read_cloud_skill_docs( - workspace_id=self.workspace.workspace_id, - skill_id=skill_id, - credentials=self.workspace._credentials, # noqa: SLF001 - ) + docs = agents_api_util.read_cloud_skill_docs( + workspace_id=self.workspace.workspace_id, + skill_id=skill_id, + credentials=self.workspace._credentials, # noqa: SLF001 ) except exc.AirbyteCloudError as error: if not agents_api_util.is_not_enabled_error(error): @@ -886,13 +884,11 @@ def read_agent_skill_docs(self, *, section: str | None = None) -> DirectAccessGu "definition_id": self.definition_id, }, ) - return DirectAccessGuidance.model_validate( - agents_api_util.read_cloud_skill_docs( - workspace_id=self.workspace.workspace_id, - skill_id=skill_id, - credentials=self.workspace._credentials, # noqa: SLF001 - section=section, - ) + return agents_api_util.read_cloud_skill_docs( + workspace_id=self.workspace.workspace_id, + skill_id=skill_id, + credentials=self.workspace._credentials, # noqa: SLF001 + section=section, ) def get_direct_access_guidance(self, *, section: str | None = None) -> DirectAccessGuidance: @@ -931,13 +927,11 @@ def get_direct_access_guidance(self, *, section: str | None = None) -> DirectAcc ) skill_id = connector_docs.destination_skill_id(self.connector_id) try: - server_docs = DirectAccessGuidance.model_validate( - agents_api_util.read_cloud_skill_docs( - workspace_id=self.workspace.workspace_id, - skill_id=skill_id, - credentials=self.workspace._credentials, # noqa: SLF001 - section=section, - ) + server_docs = agents_api_util.read_cloud_skill_docs( + workspace_id=self.workspace.workspace_id, + skill_id=skill_id, + credentials=self.workspace._credentials, # noqa: SLF001 + section=section, ) except exc.AirbyteCloudError as error: if not agents_api_util.is_not_enabled_error(error) or section is not None: diff --git a/airbyte/cloud/workspaces.py b/airbyte/cloud/workspaces.py index 202e9ec12..9e11e59cc 100644 --- a/airbyte/cloud/workspaces.py +++ b/airbyte/cloud/workspaces.py @@ -629,13 +629,11 @@ def get_agent_skill_docs( connector_docs.connector_id_from_skill_id(docs_skill_id) ) return destination.read_agent_skill_docs(section=section) - return DirectAccessGuidance.model_validate( - agents_api_util.read_cloud_skill_docs( - workspace_id=self.workspace_id, - skill_id=docs_skill_id, - credentials=self._credentials, - section=section, - ) + return agents_api_util.read_cloud_skill_docs( + workspace_id=self.workspace_id, + skill_id=docs_skill_id, + credentials=self._credentials, + section=section, ) # Deploy sources and destinations diff --git a/tests/unit_tests/test_cloud_credentials.py b/tests/unit_tests/test_cloud_credentials.py index d949039f4..4fb63f064 100644 --- a/tests/unit_tests/test_cloud_credentials.py +++ b/tests/unit_tests/test_cloud_credentials.py @@ -2412,6 +2412,30 @@ def test_cloud_connector_enablement_rejects_malformed_response( assert (exc_info.value.context or {})["path"] == "/sources/source-1/enablement" +def test_read_cloud_skill_docs_rejects_malformed_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A skill-docs response missing `metadata` raises `AirbyteCloudError`, not `ValueError`.""" + monkeypatch.setattr( + cloud_workspaces.agents_api_util, + "make_cloud_agent_request", + lambda **_: {"content": []}, + ) + credentials = cloud_credentials._AirbyteCredentials.from_auth( # noqa: SLF001 + bearer_token="token", env_vars=False + ) + + with pytest.raises( + AirbyteCloudError, match="Malformed Airbyte Cloud skill docs" + ) as exc_info: + cloud_workspaces.agents_api_util.read_cloud_skill_docs( + workspace_id="workspace-1", + skill_id="connector-source:source-1", + credentials=credentials, + ) + assert (exc_info.value.context or {})["skill_id"] == "connector-source:source-1" + + def test_cloud_connector_enablement_parses_destination_response( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/unit_tests/test_cloud_describe.py b/tests/unit_tests/test_cloud_describe.py index 49d551fd6..4654c3cf0 100644 --- a/tests/unit_tests/test_cloud_describe.py +++ b/tests/unit_tests/test_cloud_describe.py @@ -255,9 +255,9 @@ def test_direct_access_guidance_id_source_with_context_layer( _patch_context_layer(monkeypatch) calls: list[dict[str, Any]] = [] - def fake_read_docs(**kwargs: Any) -> dict[str, Any]: + def fake_read_docs(**kwargs: Any) -> DirectAccessGuidance: calls.append(kwargs) - return SKILL_DOCS_RESPONSE + return DirectAccessGuidance.model_validate(SKILL_DOCS_RESPONSE) monkeypatch.setattr( agents_api_util, @@ -438,7 +438,9 @@ def test_get_direct_access_guidance_destination_forwards_section_to_sonar( "section_id": "streams", "content": [{"type": "paragraph", "text": "Streams from Sonar."}], } - read_docs = MagicMock(return_value=section_response) + read_docs = MagicMock( + return_value=DirectAccessGuidance.model_validate(section_response) + ) monkeypatch.setattr(agents_api_util, "read_cloud_skill_docs", read_docs) monkeypatch.setattr(CloudWorkspace, "list_connections", lambda *_, **__: []) destination = _seed_destination( @@ -485,7 +487,11 @@ def test_get_direct_access_guidance_destination_merges_server_docs( """A successful docs read gets a short local intro; the outline is unchanged.""" workspace = _make_workspace(monkeypatch) _patch_context_layer(monkeypatch) - read_docs = MagicMock(return_value=DESTINATION_SKILL_DOCS_RESPONSE) + read_docs = MagicMock( + return_value=DirectAccessGuidance.model_validate( + DESTINATION_SKILL_DOCS_RESPONSE + ) + ) monkeypatch.setattr(agents_api_util, "read_cloud_skill_docs", read_docs) monkeypatch.setattr(CloudWorkspace, "list_connections", lambda *_, **__: []) destination = _seed_destination( diff --git a/tests/unit_tests/test_cloud_guidance.py b/tests/unit_tests/test_cloud_guidance.py index 2458e7d3f..2e4910622 100644 --- a/tests/unit_tests/test_cloud_guidance.py +++ b/tests/unit_tests/test_cloud_guidance.py @@ -105,9 +105,9 @@ def test_get_agent_skill_docs_reads_docs( workspace = _make_workspace(monkeypatch) calls: list[dict[str, Any]] = [] - def fake_read_docs(**kwargs: Any) -> dict[str, Any]: + def fake_read_docs(**kwargs: Any) -> DirectAccessGuidance: calls.append(kwargs) - return SKILL_DOCS_RESPONSE + return DirectAccessGuidance.model_validate(SKILL_DOCS_RESPONSE) monkeypatch.setattr(agents_api_util, "read_cloud_skill_docs", fake_read_docs) @@ -127,9 +127,9 @@ def test_get_agent_skill_docs_passes_section( workspace = _make_workspace(monkeypatch) calls: list[dict[str, Any]] = [] - def fake_read_docs(**kwargs: Any) -> dict[str, Any]: + def fake_read_docs(**kwargs: Any) -> DirectAccessGuidance: calls.append(kwargs) - return SKILL_DOCS_RESPONSE + return DirectAccessGuidance.model_validate(SKILL_DOCS_RESPONSE) monkeypatch.setattr(agents_api_util, "read_cloud_skill_docs", fake_read_docs) From f9f163f83afc824bf65d16992e515b907a94b338 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:36:02 +0000 Subject: [PATCH 2/3] fix(cloud): omit response input from skill docs validation errors Co-Authored-By: AJ Steers --- airbyte/_direct_connectors/api_util.py | 2 +- tests/unit_tests/test_cloud_credentials.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/airbyte/_direct_connectors/api_util.py b/airbyte/_direct_connectors/api_util.py index 520d2d89d..f1175bdee 100644 --- a/airbyte/_direct_connectors/api_util.py +++ b/airbyte/_direct_connectors/api_util.py @@ -376,7 +376,7 @@ def read_cloud_skill_docs( context={ "path": path, "skill_id": skill_id, - "errors": ex.errors(include_url=False), + "errors": ex.errors(include_url=False, include_input=False), }, ) from ex diff --git a/tests/unit_tests/test_cloud_credentials.py b/tests/unit_tests/test_cloud_credentials.py index 4fb63f064..d2ace121e 100644 --- a/tests/unit_tests/test_cloud_credentials.py +++ b/tests/unit_tests/test_cloud_credentials.py @@ -2419,7 +2419,7 @@ def test_read_cloud_skill_docs_rejects_malformed_response( monkeypatch.setattr( cloud_workspaces.agents_api_util, "make_cloud_agent_request", - lambda **_: {"content": []}, + lambda **_: {"content": [], "leaked": "payload-secret"}, ) credentials = cloud_credentials._AirbyteCredentials.from_auth( # noqa: SLF001 bearer_token="token", env_vars=False @@ -2434,6 +2434,7 @@ def test_read_cloud_skill_docs_rejects_malformed_response( credentials=credentials, ) assert (exc_info.value.context or {})["skill_id"] == "connector-source:source-1" + assert "payload-secret" not in str(exc_info.value) def test_cloud_connector_enablement_parses_destination_response( From 2157bf314d8bfd626c033311dc9c9c41c067d7dc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:58:32 +0000 Subject: [PATCH 3/3] fix(cloud): add AirbyteMalformedSkillDocsError for malformed skill docs responses Co-Authored-By: AJ Steers --- airbyte/_direct_connectors/api_util.py | 7 ++++--- airbyte/exceptions.py | 7 +++++++ tests/unit_tests/test_cloud_credentials.py | 7 ++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/airbyte/_direct_connectors/api_util.py b/airbyte/_direct_connectors/api_util.py index f1175bdee..1c491fecc 100644 --- a/airbyte/_direct_connectors/api_util.py +++ b/airbyte/_direct_connectors/api_util.py @@ -37,6 +37,7 @@ AirbyteCloudApiError, AirbyteCloudError, AirbyteLibInputError, + AirbyteMalformedSkillDocsError, ) from airbyte.registry import ConnectorType @@ -356,7 +357,7 @@ def read_cloud_skill_docs( The `skill_id` is sent as the `id` query parameter, with `section` added only when provided. A response that does not match `DirectAccessGuidance` raises - `AirbyteCloudError`. + `AirbyteMalformedSkillDocsError`. """ params: dict[str, Any] = {"id": skill_id} if section is not None: @@ -371,11 +372,11 @@ def read_cloud_skill_docs( try: return DirectAccessGuidance.model_validate(response) except ValidationError as ex: - raise AirbyteCloudError( + raise AirbyteMalformedSkillDocsError( message="Malformed Airbyte Cloud skill docs response.", + skill_id=skill_id, context={ "path": path, - "skill_id": skill_id, "errors": ex.errors(include_url=False, include_input=False), }, ) from ex diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 0548d36d8..9adfcb4d3 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -612,6 +612,13 @@ class AirbyteCloudApiError(AirbyteCloudError): status_code: int | None = None +@dataclass +class AirbyteMalformedSkillDocsError(AirbyteCloudError): + """Airbyte Cloud returned a skill docs response that does not match the expected shape.""" + + skill_id: str | None = None + + @dataclass class AirbyteExternalAccessNotEnabledError(AirbyteCloudError): """The connector is not enabled for external access, so it cannot execute direct actions.""" diff --git a/tests/unit_tests/test_cloud_credentials.py b/tests/unit_tests/test_cloud_credentials.py index d2ace121e..a33021d1d 100644 --- a/tests/unit_tests/test_cloud_credentials.py +++ b/tests/unit_tests/test_cloud_credentials.py @@ -40,6 +40,7 @@ from airbyte.exceptions import ( AirbyteCloudApiError, AirbyteCloudError, + AirbyteMalformedSkillDocsError, AirbyteMissingResourceError, AirbyteLibInputError, ) @@ -2415,7 +2416,7 @@ def test_cloud_connector_enablement_rejects_malformed_response( def test_read_cloud_skill_docs_rejects_malformed_response( monkeypatch: pytest.MonkeyPatch, ) -> None: - """A skill-docs response missing `metadata` raises `AirbyteCloudError`, not `ValueError`.""" + """A skill-docs response missing `metadata` raises `AirbyteMalformedSkillDocsError`.""" monkeypatch.setattr( cloud_workspaces.agents_api_util, "make_cloud_agent_request", @@ -2426,14 +2427,14 @@ def test_read_cloud_skill_docs_rejects_malformed_response( ) with pytest.raises( - AirbyteCloudError, match="Malformed Airbyte Cloud skill docs" + AirbyteMalformedSkillDocsError, match="Malformed Airbyte Cloud skill docs" ) as exc_info: cloud_workspaces.agents_api_util.read_cloud_skill_docs( workspace_id="workspace-1", skill_id="connector-source:source-1", credentials=credentials, ) - assert (exc_info.value.context or {})["skill_id"] == "connector-source:source-1" + assert exc_info.value.skill_id == "connector-source:source-1" assert "payload-secret" not in str(exc_info.value)