From c95b5992886a190d8d0614eca17471ce24e74b53 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:03:03 +0000 Subject: [PATCH 1/5] feat(mcp): show search tools to every caller and explain how to enable search Co-Authored-By: alexandre@airbyte.io --- airbyte/_direct_connectors/models.py | 8 +- airbyte/cloud/connectors.py | 15 ++- airbyte/exceptions.py | 3 + airbyte/mcp/cloud.py | 63 +++++++-- tests/unit_tests/test_cloud_direct_execute.py | 39 ++++++ tests/unit_tests/test_mcp_arg_trace_parity.py | 7 - tests/unit_tests/test_mcp_cloud.py | 125 ++++++++++++++++-- tests/unit_tests/test_mcp_tool_utils.py | 5 +- 8 files changed, 228 insertions(+), 37 deletions(-) diff --git a/airbyte/_direct_connectors/models.py b/airbyte/_direct_connectors/models.py index e2d685e36..4270a3806 100644 --- a/airbyte/_direct_connectors/models.py +++ b/airbyte/_direct_connectors/models.py @@ -376,6 +376,9 @@ class ExternalSearchResult(BaseModel): """Server warnings, plus per-index search errors collected from `metadata` so partial failures stand out, and a notice when no index was searched at all.""" + errors: list[str] = Field(default_factory=list) + """Errors that stopped the search, for example search not being enabled.""" + @field_validator("warnings", mode="before") @classmethod def _none_to_empty(cls, value: object) -> object: @@ -389,7 +392,7 @@ def _collect_index_errors(self) -> ExternalSearchResult: for entry in self.metadata if entry.error ] - if not self.metadata: + if not self.metadata and not self.errors: collected.append( "No index matched this search type and stream selection, so empty results " "do not mean there are no matches. Call `get_cloud_search_status` to see " @@ -507,6 +510,9 @@ class ExternalSearchStatusResult(BaseModel): warnings: list[str] = Field(default_factory=list) """Non-fatal issues, for example a stream filter that matched no indexed stream.""" + errors: list[str] = Field(default_factory=list) + """Errors that stopped the status check, for example search not being enabled.""" + @field_validator("warnings", mode="before") @classmethod def _none_to_empty(cls, value: object) -> object: diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index e1ae7693f..ba8ad7e66 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -597,7 +597,10 @@ def execute_search_query( # noqa: PLR0913 # Explicit args are the point of thi ) except exc.AirbyteCloudError as error: self._raise_if_feature_not_enabled( - error, ConnectorFeature.SEARCH_INDEXING, guidance=_SEARCH_NOT_ENABLED_GUIDANCE + error, + ConnectorFeature.SEARCH_INDEXING, + guidance=_SEARCH_NOT_ENABLED_GUIDANCE, + enablement_url=self.connector_url, ) raise @@ -616,7 +619,10 @@ def get_search_status(self) -> ExternalSearchStatusResult: ) except exc.AirbyteCloudError as error: self._raise_if_feature_not_enabled( - error, ConnectorFeature.SEARCH_INDEXING, guidance=_SEARCH_NOT_ENABLED_GUIDANCE + error, + ConnectorFeature.SEARCH_INDEXING, + guidance=_SEARCH_NOT_ENABLED_GUIDANCE, + enablement_url=self.connector_url, ) raise @@ -671,6 +677,11 @@ def _raise_if_feature_not_enabled( not_enabled.guidance = guidance if enablement_url is not None: not_enabled.enablement_url = enablement_url + if self._enablement is not None and self._enablement.organization_id: + not_enabled.organization_enablement_url = ( + f"{get_web_url_root(self.workspace.api_root)}/organization/" + f"{self._enablement.organization_id}/context-layer" + ) raise not_enabled from error @property diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index 64d14b0b4..c5e7a7d53 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -626,6 +626,9 @@ class AirbyteExternalAccessNotEnabledError(AirbyteCloudError): enablement_url: str | None = None """The web URL where the user can enable direct access.""" + organization_enablement_url: str | None = None + """The web URL where an organization admin can enable the Context layer, when known.""" + @dataclass class AirbyteCloudConnectionError(AirbyteCloudError): diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 05124c5f2..29c19456e 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -1892,13 +1892,35 @@ def execute_external_sql_query( return _direct_access_disabled_result(error, connector) +def _search_disabled_errors( + error: AirbyteExternalAccessNotEnabledError, + connector: CloudConnector, +) -> list[str]: + """Return user-facing search enablement guidance as MCP result errors.""" + label = error.connector_name or f"{connector.connector_type} {connector.connector_id}" + if error.organization_enablement_url: + steps = ( + f"If the Context layer isn't enabled for your organization yet, an organization " + f"admin can [click here]({error.organization_enablement_url}) to enable it first. " + f"Then [click here]({error.enablement_url}) to turn on agent access and search " + f"for {label} specifically." + ) + else: + steps = f"[Click here]({error.enablement_url}) to enable it." + return [ + f"Show the user this message as-is: Search is currently disabled for {label}. {steps}", + "Once search is enabled, the connector's synced data must be indexed before " + "searches return results. Call `get_cloud_search_status` to check which streams " + "are indexed and whether their backfill is complete.", + ] + + @mcp_tool( read_only=True, idempotent=True, open_world=True, external_access=True, tracing=lambda args: agent_action_attributes("execute_external_search_query", args), - insiders_only=True, ) def execute_external_search_query( # noqa: PLR0913 # Explicit args mirror the connector API. ctx: Context, @@ -2010,24 +2032,30 @@ def execute_external_search_query( # noqa: PLR0913 # Explicit args mirror the connector = _get_cloud_connector( _get_cloud_workspace(ctx, workspace_id), connector_id, connector_type ) - return connector.execute_search_query( - prompt, - search_type=search_type, - limit=limit, - streams=resolve_list_of_dicts(streams, arg_name="streams"), - lookback_seconds=lookback_seconds, - max_context_chars=max_context_chars, - min_similarity=min_similarity, - max_similarity_diff=max_similarity_diff, - destination_id=destination_id, - ) + try: + return connector.execute_search_query( + prompt, + search_type=search_type, + limit=limit, + streams=resolve_list_of_dicts(streams, arg_name="streams"), + lookback_seconds=lookback_seconds, + max_context_chars=max_context_chars, + min_similarity=min_similarity, + max_similarity_diff=max_similarity_diff, + destination_id=destination_id, + ) + except AirbyteExternalAccessNotEnabledError as error: + if error.enablement_url is None: + raise + return ExternalSearchResult( + hits=[], metadata=[], errors=_search_disabled_errors(error, connector) + ) @mcp_tool( read_only=True, idempotent=True, external_access=True, - insiders_only=True, ) def get_cloud_search_status( ctx: Context, @@ -2080,7 +2108,14 @@ def get_cloud_search_status( connector = _get_cloud_connector( _get_cloud_workspace(ctx, workspace_id), connector_id, connector_type ) - status = connector.get_search_status() + try: + status = connector.get_search_status() + except AirbyteExternalAccessNotEnabledError as error: + if error.enablement_url is None: + raise + return ExternalSearchStatusResult( + sources=[], errors=_search_disabled_errors(error, connector) + ) if stream_name is None and namespace is None: return status diff --git a/tests/unit_tests/test_cloud_direct_execute.py b/tests/unit_tests/test_cloud_direct_execute.py index e30c044c1..d4356f7ce 100644 --- a/tests/unit_tests/test_cloud_direct_execute.py +++ b/tests/unit_tests/test_cloud_direct_execute.py @@ -15,6 +15,7 @@ from airbyte._direct_connectors import api_util as agents_api_util from airbyte._util import api_util from airbyte._direct_connectors.models import ( + ConnectorEnablement, ExternalApiExecuteResult, ExternalSearchHit, ExternalSearchResult, @@ -1177,6 +1178,43 @@ def test_search_input_validation( assert calls == [] +@pytest.mark.parametrize("method_name", ["execute_search_query", "get_search_status"]) +@pytest.mark.parametrize( + ("organization_id", "expected_url"), + [ + pytest.param( + "org-1", + "https://cloud.airbyte.com/organization/org-1/context-layer", + id="organization_known", + ), + pytest.param(None, None, id="organization_unknown"), + ], +) +def test_search_not_enabled_sets_organization_enablement_url( + monkeypatch: pytest.MonkeyPatch, + method_name: str, + organization_id: str | None, + expected_url: str | None, +) -> None: + """A search not-enabled error links to the org Context layer page when the org is known.""" + workspace = _make_workspace(monkeypatch) + _patch_context_layer(monkeypatch) + _patch_search(monkeypatch, {}, error=AirbyteCloudApiError(status_code=403)) + source = _seed_source(workspace, "source-1", "GitHub Issues") + source._enablement = ConnectorEnablement( # noqa: SLF001 + enable_agent_access=False, + enable_indexing=False, + organizationId=organization_id, + ) + args = ("refunds",) if method_name == "execute_search_query" else () + + with pytest.raises(AirbyteExternalAccessNotEnabledError) as exc_info: + getattr(source, method_name)(*args) + + assert exc_info.value.enablement_url == source.connector_url + assert exc_info.value.organization_enablement_url == expected_url + + @pytest.mark.parametrize("method_name", ["execute_search_query", "get_search_status"]) @pytest.mark.parametrize( ("status_code", "search_flag", "expected_exc", "expected_status"), @@ -1240,6 +1278,7 @@ def test_search_error_handling( assert exc_info.value.connector_id == "source-1" assert exc_info.value.connector_name == "GitHub Issues" assert "search_indexing" in (exc_info.value.guidance or "") + assert exc_info.value.enablement_url == source.connector_url else: assert not isinstance(exc_info.value, AirbyteExternalAccessNotEnabledError) assert isinstance(exc_info.value, AirbyteCloudApiError) diff --git a/tests/unit_tests/test_mcp_arg_trace_parity.py b/tests/unit_tests/test_mcp_arg_trace_parity.py index e87ed0282..a30295b3a 100644 --- a/tests/unit_tests/test_mcp_arg_trace_parity.py +++ b/tests/unit_tests/test_mcp_arg_trace_parity.py @@ -22,7 +22,6 @@ from opentelemetry.instrumentation.requests import RequestsInstrumentor from opentelemetry.sdk.trace import ReadableSpan -from airbyte.constants import MCP_INSIDERS_ENV_VAR from airbyte.mcp import _otel as observability from airbyte.mcp import cloud from airbyte.mcp.server import app as server_app @@ -60,12 +59,6 @@ } -@pytest.fixture(autouse=True) -def _insiders_mode(monkeypatch: pytest.MonkeyPatch) -> None: - """Advertise the insiders-only search tools so their args are covered.""" - monkeypatch.setenv(MCP_INSIDERS_ENV_VAR, "1") - - def _plan() -> dict[str, dict[str, str]]: plan = asyncio.run(trace_plan(server_app)) return {name: entry["args"] for name, entry in plan.items()} diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index 45536142f..bbc46e496 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -1678,10 +1678,14 @@ def execute_sql_query(self, sql: str, **kwargs: object) -> object: def execute_search_query(self, prompt: str, **kwargs: object) -> object: self.calls.append(("search", {"prompt": prompt, **kwargs})) + if self.error is not None: + raise self.error return ExternalSearchResult(hits=[], metadata=[]) def get_search_status(self) -> object: self.calls.append(("search_status", {})) + if self.error is not None: + raise self.error return ExternalSearchStatusResult.model_validate({ "sources": [ { @@ -2182,20 +2186,124 @@ def test_search_tools_use_connector_type_without_probe( assert [kind for kind, _ in connector.calls] == ["search", "search_status"] +_SEARCH_ENABLEMENT_URL = ( + "https://cloud.airbyte.com/workspaces/workspace-1/source/source-1" +) + + +_ORG_ENABLEMENT_URL = "https://cloud.airbyte.com/organization/org-1/context-layer" + + +@pytest.mark.parametrize( + ("connector_name", "organization_url", "expected_message"), + [ + pytest.param( + "GitHub Issues", + None, + "Show the user this message as-is: Search is currently disabled for " + f"GitHub Issues. [Click here]({_SEARCH_ENABLEMENT_URL}) to enable it.", + id="connector_link_only", + ), + pytest.param( + None, + None, + "Show the user this message as-is: Search is currently disabled for " + f"source source-1. [Click here]({_SEARCH_ENABLEMENT_URL}) to enable it.", + id="fallback_label", + ), + pytest.param( + "GitHub Issues", + _ORG_ENABLEMENT_URL, + "Show the user this message as-is: Search is currently disabled for " + "GitHub Issues. If the Context layer isn't enabled for your organization yet, " + f"an organization admin can [click here]({_ORG_ENABLEMENT_URL}) to enable it " + f"first. Then [click here]({_SEARCH_ENABLEMENT_URL}) to turn on agent access " + "and search for GitHub Issues specifically.", + id="organization_and_connector_links", + ), + ], +) +def test_search_tools_return_search_disabled_errors( + monkeypatch: pytest.MonkeyPatch, + connector_name: str | None, + organization_url: str | None, + expected_message: str, +) -> None: + """Both search tools return the enablement links and agent guidance when disabled.""" + error = AirbyteExternalAccessNotEnabledError( + connector_id="source-1", + connector_name=connector_name, + enablement_url=_SEARCH_ENABLEMENT_URL, + organization_enablement_url=organization_url, + ) + connector = _RecordingExecuteConnector(error=error) + _execute_workspace(monkeypatch, connector) + + search = cloud_mcp.execute_external_search_query( + None, + connector_id="source-1", + prompt="refund requests", + workspace_id=None, + ) + status = cloud_mcp.get_cloud_search_status( + None, + connector_id="source-1", + stream_name="issues", + workspace_id=None, + ) + + expected_errors = [ + expected_message, + "Once search is enabled, the connector's synced data must be indexed before " + "searches return results. Call `get_cloud_search_status` to check which streams " + "are indexed and whether their backfill is complete.", + ] + assert search.errors == expected_errors + assert search.hits == [] + assert search.warnings == [] + assert status.errors == expected_errors + assert status.sources == [] + assert status.warnings == [] + + +@pytest.mark.parametrize( + "tool_name", ["execute_external_search_query", "get_cloud_search_status"] +) +def test_search_tools_reraise_without_enablement_url( + monkeypatch: pytest.MonkeyPatch, + tool_name: str, +) -> None: + """Search tools re-raise not-enabled errors that lack an enablement URL.""" + error = AirbyteExternalAccessNotEnabledError(connector_id="source-1") + connector = _RecordingExecuteConnector(error=error) + _execute_workspace(monkeypatch, connector) + kwargs: dict[str, object] = ( + {"prompt": "refund requests"} + if tool_name == "execute_external_search_query" + else {} + ) + + with pytest.raises(AirbyteExternalAccessNotEnabledError) as exc_info: + getattr(cloud_mcp, tool_name)( + None, connector_id="source-1", workspace_id=None, **kwargs + ) + + assert exc_info.value is error + + @pytest.mark.parametrize( - ("insiders_env", "expected"), + "insiders_env", [ - pytest.param(None, False, id="hidden_by_default"), - pytest.param("1", True, id="shown_with_insiders_env"), - pytest.param("0", False, id="hidden_with_insiders_env_off"), + pytest.param(None, id="insiders_unset"), + pytest.param("1", id="insiders_on"), + pytest.param("0", id="insiders_off"), ], ) -def test_search_tools_are_insiders_only( +def test_search_tools_are_always_advertised( monkeypatch: pytest.MonkeyPatch, insiders_env: str | None, - expected: bool, ) -> None: - """The search tools are advertised only in insiders mode.""" + """The search tools are advertised to every caller, whatever the insiders mode.""" from airbyte.mcp import server if insiders_env is None: @@ -2203,8 +2311,7 @@ def test_search_tools_are_insiders_only( else: monkeypatch.setenv(MCP_INSIDERS_ENV_VAR, insiders_env) names = {tool.name for tool in asyncio.run(server.app.list_tools())} - for tool_name in ("execute_external_search_query", "get_cloud_search_status"): - assert (tool_name in names) is expected + assert {"execute_external_search_query", "get_cloud_search_status"} <= names @pytest.mark.parametrize( diff --git a/tests/unit_tests/test_mcp_tool_utils.py b/tests/unit_tests/test_mcp_tool_utils.py index 4b145e98c..23f0b96ee 100644 --- a/tests/unit_tests/test_mcp_tool_utils.py +++ b/tests/unit_tests/test_mcp_tool_utils.py @@ -289,10 +289,7 @@ def test_server_tools_register_expected_policies() -> None: insiders_tools = { name for name, policy in _TOOL_POLICIES.items() if policy.insiders_only } - assert insiders_tools == { - "execute_external_search_query", - "get_cloud_search_status", - } + assert insiders_tools == set() @pytest.mark.parametrize( From c52a43efcc892b6cbabdc0ca472c99b81e10fb20 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:09:11 +0000 Subject: [PATCH 2/5] fix(cloud): treat a 409 from search as search not enabled Co-Authored-By: alexandre@airbyte.io --- airbyte/cloud/connectors.py | 12 +++++++++--- tests/unit_tests/test_cloud_direct_execute.py | 10 +++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index ba8ad7e66..ed44ee34f 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -636,14 +636,20 @@ def _raise_if_feature_not_enabled( ) -> None: """Map a forbidden or not-found `error` to a clear error, when one applies. - Only a 403 or 404 is re-checked against the connector's enablement, and only an - enablement lookup reporting `feature` as disabled raises + Only a 403 or 404 (or a 409 for `SEARCH_INDEXING`) is re-checked against the + connector's enablement, and only an enablement lookup reporting `feature` as disabled raises `AirbyteExternalAccessNotEnabledError`. When the enablement lookup itself fails, `error` is re-raised instead: with wrong-ID guidance on a 404 (no connector of this kind), with access guidance on a 403, and as-is otherwise. When `feature` is enabled, this returns. """ - if not agents_api_util.is_not_enabled_error(error): + # Search also reports a connector with search turned off as a 409 conflict. + search_disabled = ( + feature == ConnectorFeature.SEARCH_INDEXING + and isinstance(error, exc.AirbyteCloudApiError) + and error.status_code == HTTPStatus.CONFLICT + ) + if not (agents_api_util.is_not_enabled_error(error) or search_disabled): return try: enabled = self.is_feature_enabled(feature) diff --git a/tests/unit_tests/test_cloud_direct_execute.py b/tests/unit_tests/test_cloud_direct_execute.py index d4356f7ce..73ce027eb 100644 --- a/tests/unit_tests/test_cloud_direct_execute.py +++ b/tests/unit_tests/test_cloud_direct_execute.py @@ -1226,6 +1226,14 @@ def test_search_not_enabled_sets_organization_enablement_url( None, id="forbidden_disabled_raises_not_enabled", ), + pytest.param( + 409, + False, + AirbyteExternalAccessNotEnabledError, + None, + id="conflict_disabled_raises_not_enabled", + ), + pytest.param(409, True, AirbyteCloudError, 409, id="conflict_enabled_reraises"), pytest.param( 404, True, AirbyteCloudError, 404, id="not_found_enabled_reraises" ), @@ -1269,7 +1277,7 @@ def test_search_error_handling( with flag_patch as flag_mock, pytest.raises(expected_exc) as exc_info: getattr(source, method_name)(*args) - if status_code in {403, 404}: + if status_code in {403, 404, 409}: flag_mock.assert_called_once_with(ConnectorFeature.SEARCH_INDEXING) else: flag_mock.assert_not_called() From 11478cc269537072290dfde851121f489e7d7380 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:16:08 +0000 Subject: [PATCH 3/5] docs(mcp): point search error translation at PLAT-1557 Co-Authored-By: alexandre@airbyte.io --- airbyte/mcp/cloud.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 29c19456e..3b24493ea 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -1896,7 +1896,11 @@ def _search_disabled_errors( error: AirbyteExternalAccessNotEnabledError, connector: CloudConnector, ) -> list[str]: - """Return user-facing search enablement guidance as MCP result errors.""" + """Return user-facing search enablement guidance as MCP result errors. + + TODO: Move this translation to the platform, which drops Sonar's specific search error + details today: https://linear.app/airbyteio/issue/PLAT-1557 + """ label = error.connector_name or f"{connector.connector_type} {connector.connector_id}" if error.organization_enablement_url: steps = ( From ded5a95c969e941b0aadca26d7f910f42fc13e14 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:02:47 +0000 Subject: [PATCH 4/5] fix(mcp): redact search-disabled error text Co-Authored-By: alexandre@airbyte.io --- airbyte/mcp/cloud.py | 4 +++- tests/unit_tests/test_mcp_cloud.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/airbyte/mcp/cloud.py b/airbyte/mcp/cloud.py index 3b24493ea..5684d767b 100644 --- a/airbyte/mcp/cloud.py +++ b/airbyte/mcp/cloud.py @@ -1912,7 +1912,9 @@ def _search_disabled_errors( else: steps = f"[Click here]({error.enablement_url}) to enable it." return [ - f"Show the user this message as-is: Search is currently disabled for {label}. {steps}", + redact_agent_text( + f"Show the user this message as-is: Search is currently disabled for {label}. {steps}" + ), "Once search is enabled, the connector's synced data must be indexed before " "searches return results. Call `get_cloud_search_status` to check which streams " "are indexed and whether their backfill is complete.", diff --git a/tests/unit_tests/test_mcp_cloud.py b/tests/unit_tests/test_mcp_cloud.py index bbc46e496..c4496adf5 100644 --- a/tests/unit_tests/test_mcp_cloud.py +++ b/tests/unit_tests/test_mcp_cloud.py @@ -2266,6 +2266,31 @@ def test_search_tools_return_search_disabled_errors( assert status.warnings == [] +def test_search_disabled_errors_are_redacted(monkeypatch: pytest.MonkeyPatch) -> None: + """Credentials in the connector name or enablement URLs never reach the agent.""" + error = AirbyteExternalAccessNotEnabledError( + connector_id="source-1", + connector_name="GitHub Bearer abc.def-123", + enablement_url="https://user:pw@cloud.airbyte.com/source/source-1?token=s3cr3t", + organization_enablement_url=f"{_ORG_ENABLEMENT_URL}?token=s3cr3t", + ) + connector = _RecordingExecuteConnector(error=error) + _execute_workspace(monkeypatch, connector) + + search = cloud_mcp.execute_external_search_query( + None, + connector_id="source-1", + prompt="refund requests", + workspace_id=None, + ) + + message = search.errors[0] + for secret in ("abc.def-123", "user:pw", "s3cr3t"): + assert secret not in message + assert "Bearer " in message + assert f"[click here]({_ORG_ENABLEMENT_URL})" in message + + @pytest.mark.parametrize( "tool_name", ["execute_external_search_query", "get_cloud_search_status"] ) From d023ceabf42f46a5aa8111ac9472bd8dd08441fd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:09:01 +0000 Subject: [PATCH 5/5] fix(cloud): relay Cloud detail on a search 409 Co-Authored-By: alexandre@airbyte.io --- airbyte/cloud/connectors.py | 25 +++++++--- tests/unit_tests/test_cloud_direct_execute.py | 48 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/airbyte/cloud/connectors.py b/airbyte/cloud/connectors.py index ed44ee34f..b66fdcfa1 100644 --- a/airbyte/cloud/connectors.py +++ b/airbyte/cloud/connectors.py @@ -117,6 +117,15 @@ ) +_GENERIC_CONFLICT_MESSAGES = frozenset( + { + "The request conflicts with the current resource state.", + "Airbyte Cloud returned HTTP 409.", + } +) +"""409 messages that carry no Cloud detail: the platform default and PyAirbyte's fallback.""" + + class _ConnectorDefinitionLike(Protocol): """The connector definition fields PyAirbyte reads from the Cloud public API.""" @@ -636,18 +645,20 @@ def _raise_if_feature_not_enabled( ) -> None: """Map a forbidden or not-found `error` to a clear error, when one applies. - Only a 403 or 404 (or a 409 for `SEARCH_INDEXING`) is re-checked against the - connector's enablement, and only an enablement lookup reporting `feature` as disabled raises - `AirbyteExternalAccessNotEnabledError`. When the enablement lookup itself fails, - `error` is re-raised instead: with wrong-ID guidance on a 404 (no connector of - this kind), with access guidance on a 403, and as-is otherwise. When `feature` is - enabled, this returns. + Only a 403 or 404 (or a 409 without Cloud detail for `SEARCH_INDEXING`) is re-checked + against the connector's enablement, and only an enablement lookup reporting `feature` + as disabled raises `AirbyteExternalAccessNotEnabledError`. When the enablement lookup + itself fails, `error` is re-raised instead: with wrong-ID guidance on a 404 (no + connector of this kind), with access guidance on a 403, and as-is otherwise. When + `feature` is enabled, this returns. """ - # Search also reports a connector with search turned off as a 409 conflict. + # Search also reports a connector with search turned off as a 409 conflict. A 409 + # with its own Cloud detail is relayed as-is instead. search_disabled = ( feature == ConnectorFeature.SEARCH_INDEXING and isinstance(error, exc.AirbyteCloudApiError) and error.status_code == HTTPStatus.CONFLICT + and (error.message is None or error.message in _GENERIC_CONFLICT_MESSAGES) ) if not (agents_api_util.is_not_enabled_error(error) or search_disabled): return diff --git a/tests/unit_tests/test_cloud_direct_execute.py b/tests/unit_tests/test_cloud_direct_execute.py index 73ce027eb..b44f5787d 100644 --- a/tests/unit_tests/test_cloud_direct_execute.py +++ b/tests/unit_tests/test_cloud_direct_execute.py @@ -1293,6 +1293,54 @@ def test_search_error_handling( assert exc_info.value.status_code == expected_status +@pytest.mark.parametrize("method_name", ["execute_search_query", "get_search_status"]) +@pytest.mark.parametrize( + ("message", "expected_exc"), + [ + pytest.param( + "The request conflicts with the current resource state.", + AirbyteExternalAccessNotEnabledError, + id="platform_default_detail_raises_not_enabled", + ), + pytest.param( + "Airbyte Cloud returned HTTP 409.", + AirbyteExternalAccessNotEnabledError, + id="status_fallback_raises_not_enabled", + ), + pytest.param( + "Search is turned off for GitHub Issues.", + AirbyteCloudApiError, + id="cloud_detail_relayed", + ), + ], +) +def test_search_conflict_relays_cloud_detail( + monkeypatch: pytest.MonkeyPatch, + method_name: str, + message: str, + expected_exc: type[Exception], +) -> None: + """A search 409 with its own Cloud detail keeps that detail instead of local guidance.""" + workspace = _make_workspace(monkeypatch) + _patch_context_layer(monkeypatch) + cloud_error = AirbyteCloudApiError(status_code=409, message=message) + _patch_search(monkeypatch, {}, error=cloud_error) + source = _seed_source(workspace, "source-1", "GitHub Issues") + args = ("refunds",) if method_name == "execute_search_query" else () + + with ( + patch.object(CloudConnector, "is_feature_enabled", return_value=False), + pytest.raises(expected_exc) as exc_info, + ): + getattr(source, method_name)(*args) + + if expected_exc is AirbyteCloudApiError: + assert exc_info.value is cloud_error + assert exc_info.value.message == message + else: + assert exc_info.value.__cause__ is cloud_error + + def test_search_methods_raise_without_context_layer( monkeypatch: pytest.MonkeyPatch, ) -> None: