From aa4e330a3dfdb9564c36dbbce1b20affeb0f43ce Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:30:16 +0000 Subject: [PATCH 1/3] feat(exceptions): expose __cause__ as original_exception Co-Authored-By: AJ Steers --- airbyte/_connector_base.py | 4 +- airbyte/destinations/base.py | 2 +- airbyte/exceptions.py | 32 +++++++++++---- airbyte/secrets/hydration.py | 2 +- airbyte/sources/util.py | 2 +- tests/unit_tests/test_exceptions.py | 62 ++++++++++++++++++++++++++++- 6 files changed, 91 insertions(+), 13 deletions(-) diff --git a/airbyte/_connector_base.py b/airbyte/_connector_base.py index f7917c1e7..6200eec23 100644 --- a/airbyte/_connector_base.py +++ b/airbyte/_connector_base.py @@ -383,7 +383,7 @@ def check(self) -> None: raise exc.AirbyteConnectorCheckFailedError( connector_name=self.name, original_exception=ex, - ) from None + ) from ex def install(self) -> None: """Install the connector if it is not yet installed.""" @@ -518,7 +518,7 @@ def _execute( connector_name=self.name, log_text=self._last_log_messages, original_exception=e, - ) from None + ) from e __all__ = [ diff --git a/airbyte/destinations/base.py b/airbyte/destinations/base.py index b87f8fe56..4b0838633 100644 --- a/airbyte/destinations/base.py +++ b/airbyte/destinations/base.py @@ -360,7 +360,7 @@ def _write_airbyte_message_stream( connector_name=self.name, log_text=self._last_log_messages, original_exception=ex, - ) from None + ) from ex __all__ = [ diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index bc46d106c..e3d311f10 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -39,7 +39,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass +from dataclasses import InitVar, dataclass from pathlib import Path from textwrap import indent from typing import TYPE_CHECKING, Any, Protocol @@ -80,7 +80,13 @@ class AirbyteLibError(Exception): print_full_log: bool = AIRBYTE_PRINT_FULL_ERROR_LOGS context: dict[str, Any] | None = None message: str | None = None - original_exception: Exception | None = None + original_exception: InitVar[BaseException | None] = None + + def __post_init__(self, original_exception: BaseException | None) -> None: + """Set the Python cause from the backwards-compatible constructor argument.""" + self._show_original_exception = original_exception is not None + if original_exception is not None: + self.__cause__ = original_exception def get_message(self) -> str: """Return the best description for the exception. @@ -136,8 +142,8 @@ def __str__(self) -> str: exception_str += f"\n Log output: \n {indent(self.log_text, ' ')}" - if self.original_exception: - exception_str += VERTICAL_SEPARATOR + f"\nCaused by: {self.original_exception!s}" + if self._show_original_exception and self.__cause__ is not None: + exception_str += VERTICAL_SEPARATOR + f"\nCaused by: {self.__cause__!s}" if self.log_file: if self.print_full_log: @@ -237,8 +243,9 @@ class AirbyteNoCloudCredentialsError(AirbyteLibInputError): _allow_bearer: bool = True _env_vars: bool = True - def __post_init__(self) -> None: + def __post_init__(self, original_exception: BaseException | None) -> None: """Set guidance for the current execution mode.""" + super().__post_init__(original_exception) if self.guidance is not None: return if is_hosted_mcp_mode(): @@ -278,8 +285,9 @@ class AirbyteMissingWorkspaceContextError(AirbyteLibInputError): guidance: str | None = None - def __post_init__(self) -> None: + def __post_init__(self, original_exception: BaseException | None) -> None: """Set guidance for the current execution mode.""" + super().__post_init__(original_exception) if self.guidance is not None: return if is_mcp_mode(): @@ -432,8 +440,9 @@ class AirbyteConnectorError(AirbyteLibError): connector_name: str | None = None - def __post_init__(self) -> None: + def __post_init__(self, original_exception: BaseException | None) -> None: """Set the log file path for the connector.""" + super().__post_init__(original_exception) self.log_file = self._get_log_file() if not self.guidance and self.log_file: self.guidance = "Please review the log file for more information." @@ -767,3 +776,12 @@ class AirbyteLibDataLossWarning(AirbyteLibWarning): > from airbyte.exceptions import AirbyteLibDataLossWarning > warnings.filterwarnings("ignore", category=AirbyteLibDataLossWarning) """ + + +def _get_original_exception(self: AirbyteLibError) -> BaseException | None: + """Return the Python cause of an Airbyte library exception.""" + return self.__cause__ + + +# Attach after dataclass creation so the property does not become the InitVar default. +AirbyteLibError.original_exception = property(_get_original_exception) diff --git a/airbyte/secrets/hydration.py b/airbyte/secrets/hydration.py index 257dad0e5..d60d893b7 100644 --- a/airbyte/secrets/hydration.py +++ b/airbyte/secrets/hydration.py @@ -122,7 +122,7 @@ def _get_global_secrets_mask() -> list[str]: raise AirbyteLibInternalError( "Failed to parse spec mask.", original_exception=ex, - ) from None + ) from ex def _get_connector_secrets_mask( diff --git a/airbyte/sources/util.py b/airbyte/sources/util.py index 65f84737b..9db6c231c 100644 --- a/airbyte/sources/util.py +++ b/airbyte/sources/util.py @@ -143,7 +143,7 @@ def get_benchmark_source( message="Invalid number format.", original_exception=ex, input_value=str(num_records), - ) from None + ) from ex return get_source( name="source-e2e-test", diff --git a/tests/unit_tests/test_exceptions.py b/tests/unit_tests/test_exceptions.py index 7777139c6..165c4e0bf 100644 --- a/tests/unit_tests/test_exceptions.py +++ b/tests/unit_tests/test_exceptions.py @@ -2,10 +2,13 @@ from __future__ import annotations import inspect -import airbyte.exceptions as exceptions_module + import pytest +import airbyte.exceptions as exceptions_module +from airbyte._util.destination_smoke_tests import _sanitize_error from airbyte.exceptions import ( + AirbyteCloudError, AirbyteMissingWorkspaceContextError, AirbyteNoCloudCredentialsError, ) @@ -146,5 +149,62 @@ def test_missing_workspace_context_error_guidance( assert "Env Vars" not in str(error) +def test_original_exception_reflects_raise_from_cause() -> None: + inner = ValueError("inner") + + with pytest.raises(AirbyteCloudError) as exc_info: + raise AirbyteCloudError(message="m") from inner + + error = exc_info.value + assert getattr(error, "original_exception") is inner + assert "Caused by:" not in str(error) + + +def test_original_exception_constructor_argument_sets_cause() -> None: + inner = ValueError("inner") + error = AirbyteCloudError(message="m", original_exception=inner) + + assert error.__cause__ is inner + assert getattr(error, "original_exception") is inner + assert "Caused by:" in str(error) + assert "inner" in str(error) + + replacement = ValueError("replacement") + with pytest.raises(AirbyteCloudError) as exc_info: + raise error from replacement + + assert getattr(exc_info.value, "original_exception") is replacement + assert "Caused by:" in str(exc_info.value) + assert "replacement" in str(exc_info.value) + + +def test_original_exception_is_none_without_a_cause() -> None: + error = AirbyteCloudError(message="m") + + assert getattr(error, "original_exception") is None + assert "Caused by" not in str(error) + + +def test_original_exception_constructor_argument_survives_subclass_post_init() -> None: + inner = ValueError("inner") + error = AirbyteNoCloudCredentialsError( + guidance="guidance", + original_exception=inner, + ) + + assert error.guidance == "guidance" + assert error.__cause__ is inner + assert getattr(error, "original_exception") is inner + + +def test_sanitize_error_walks_raise_from_chain() -> None: + inner = AirbyteCloudError(message="inner") + + with pytest.raises(AirbyteCloudError) as exc_info: + raise AirbyteCloudError(message="outer") from inner + + assert _sanitize_error(exc_info.value) == "AirbyteCloudError: inner" + + if __name__ == "__main__": pytest.main() From 1a731d0e1204ffb95b1ea693301a945530716803 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:43:44 +0000 Subject: [PATCH 2/3] fix(exceptions): use typed descriptor for original_exception; keep cause traversal opt-in Co-Authored-By: AJ Steers --- airbyte/_util/destination_smoke_tests.py | 29 ++++++---- airbyte/exceptions.py | 61 ++++++++++++-------- tests/unit_tests/test_exceptions.py | 73 +++++++++++++++++++++--- 3 files changed, 119 insertions(+), 44 deletions(-) diff --git a/airbyte/_util/destination_smoke_tests.py b/airbyte/_util/destination_smoke_tests.py index fb4bc8914..310bb1981 100644 --- a/airbyte/_util/destination_smoke_tests.py +++ b/airbyte/_util/destination_smoke_tests.py @@ -265,6 +265,14 @@ def _prepare_destination_config( destination.set_config(config, validate=False) +def _get_opted_in_original_exception(error: BaseException) -> BaseException | None: + if not getattr(error, "_original_exception_opt_in", False): + return None + + original_exception = getattr(error, "original_exception", None) + return original_exception if isinstance(original_exception, BaseException) else None + + def _extract_trace_error_from_log(ex: Exception) -> str | None: """Search the connector log file for a TRACE ERROR `internal_message`. @@ -278,12 +286,13 @@ def _extract_trace_error_from_log(ex: Exception) -> str | None: """ # Walk the exception chain looking for a log_file path log_file: Path | None = None - current: Exception | None = ex + current: BaseException | None = ex while current is not None: - if hasattr(current, "log_file") and current.log_file is not None: - log_file = current.log_file + current_log_file = getattr(current, "log_file", None) + if isinstance(current_log_file, Path): + log_file = current_log_file break - current = getattr(current, "original_exception", None) + current = _get_opted_in_original_exception(current) if log_file is None or not log_file.exists(): return None @@ -322,8 +331,8 @@ def _sanitize_error(ex: Exception) -> str: `password authentication failed for user "bob"`). Many Java-based connectors emit a generic user-facing `message` while the real detail lives only in `internal_message`. - 2. **Exception chain** - Walks `original_exception` to find the deepest - exception with a message more specific than the top-level wrapper. + 2. **Exception chain** - Walks explicitly opted-in `original_exception` + links to find the deepest specific message. 3. **Top-level exception** - Falls back to `get_message()` or `str()`. Uses `get_message()` when available (PyAirbyte exceptions) to avoid @@ -334,10 +343,10 @@ def _sanitize_error(ex: Exception) -> str: if trace_msg: return f"{type(ex).__name__}: {trace_msg}" - # 2. Walk original_exception chain to find the deepest specific message. - deepest = ex - while hasattr(deepest, "original_exception") and deepest.original_exception is not None: - deepest = deepest.original_exception + # 2. Walk opted-in original_exception links to find the deepest message. + deepest: BaseException = ex + while (original_exception := _get_opted_in_original_exception(deepest)) is not None: + deepest = original_exception if deepest is not ex: deep_msg = ( diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index e3d311f10..c895a9d18 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -39,10 +39,10 @@ from __future__ import annotations import logging -from dataclasses import InitVar, dataclass +from dataclasses import dataclass from pathlib import Path from textwrap import indent -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, overload from airbyte._util.meta import is_mcp_mode from airbyte.constants import ( @@ -66,6 +66,35 @@ VERTICAL_SEPARATOR = "\n" + "-" * 60 +class _CauseDescriptor: + @overload + def __get__(self, instance: None, owner: type[Any] | None = None) -> None: ... + + @overload + def __get__( + self, + instance: AirbyteLibError, + owner: type[Any] | None = None, + ) -> BaseException | None: ... + + def __get__( + self, + instance: AirbyteLibError | None, + owner: type[Any] | None = None, + ) -> BaseException | None: + if instance is None: + return None + return instance.__cause__ + + def __set__(self, instance: AirbyteLibError, value: BaseException | None) -> None: + if value is None: + instance.__dict__["_original_exception_opt_in"] = False + return + + instance.__cause__ = value + instance.__dict__["_original_exception_opt_in"] = True + + # Base error class @@ -80,13 +109,7 @@ class AirbyteLibError(Exception): print_full_log: bool = AIRBYTE_PRINT_FULL_ERROR_LOGS context: dict[str, Any] | None = None message: str | None = None - original_exception: InitVar[BaseException | None] = None - - def __post_init__(self, original_exception: BaseException | None) -> None: - """Set the Python cause from the backwards-compatible constructor argument.""" - self._show_original_exception = original_exception is not None - if original_exception is not None: - self.__cause__ = original_exception + original_exception: _CauseDescriptor = _CauseDescriptor() def get_message(self) -> str: """Return the best description for the exception. @@ -142,7 +165,7 @@ def __str__(self) -> str: exception_str += f"\n Log output: \n {indent(self.log_text, ' ')}" - if self._show_original_exception and self.__cause__ is not None: + if getattr(self, "_original_exception_opt_in", False) and self.__cause__ is not None: exception_str += VERTICAL_SEPARATOR + f"\nCaused by: {self.__cause__!s}" if self.log_file: @@ -243,9 +266,8 @@ class AirbyteNoCloudCredentialsError(AirbyteLibInputError): _allow_bearer: bool = True _env_vars: bool = True - def __post_init__(self, original_exception: BaseException | None) -> None: + def __post_init__(self) -> None: """Set guidance for the current execution mode.""" - super().__post_init__(original_exception) if self.guidance is not None: return if is_hosted_mcp_mode(): @@ -285,9 +307,8 @@ class AirbyteMissingWorkspaceContextError(AirbyteLibInputError): guidance: str | None = None - def __post_init__(self, original_exception: BaseException | None) -> None: + def __post_init__(self) -> None: """Set guidance for the current execution mode.""" - super().__post_init__(original_exception) if self.guidance is not None: return if is_mcp_mode(): @@ -440,9 +461,8 @@ class AirbyteConnectorError(AirbyteLibError): connector_name: str | None = None - def __post_init__(self, original_exception: BaseException | None) -> None: + def __post_init__(self) -> None: """Set the log file path for the connector.""" - super().__post_init__(original_exception) self.log_file = self._get_log_file() if not self.guidance and self.log_file: self.guidance = "Please review the log file for more information." @@ -776,12 +796,3 @@ class AirbyteLibDataLossWarning(AirbyteLibWarning): > from airbyte.exceptions import AirbyteLibDataLossWarning > warnings.filterwarnings("ignore", category=AirbyteLibDataLossWarning) """ - - -def _get_original_exception(self: AirbyteLibError) -> BaseException | None: - """Return the Python cause of an Airbyte library exception.""" - return self.__cause__ - - -# Attach after dataclass creation so the property does not become the InitVar default. -AirbyteLibError.original_exception = property(_get_original_exception) diff --git a/tests/unit_tests/test_exceptions.py b/tests/unit_tests/test_exceptions.py index 165c4e0bf..b6984067c 100644 --- a/tests/unit_tests/test_exceptions.py +++ b/tests/unit_tests/test_exceptions.py @@ -2,13 +2,19 @@ from __future__ import annotations import inspect +from dataclasses import dataclass +from pathlib import Path import pytest import airbyte.exceptions as exceptions_module -from airbyte._util.destination_smoke_tests import _sanitize_error +from airbyte._util.destination_smoke_tests import ( + _extract_trace_error_from_log, + _sanitize_error, +) from airbyte.exceptions import ( AirbyteCloudError, + AirbyteLibError, AirbyteMissingWorkspaceContextError, AirbyteNoCloudCredentialsError, ) @@ -156,7 +162,7 @@ def test_original_exception_reflects_raise_from_cause() -> None: raise AirbyteCloudError(message="m") from inner error = exc_info.value - assert getattr(error, "original_exception") is inner + assert error.original_exception is inner assert "Caused by:" not in str(error) @@ -165,7 +171,7 @@ def test_original_exception_constructor_argument_sets_cause() -> None: error = AirbyteCloudError(message="m", original_exception=inner) assert error.__cause__ is inner - assert getattr(error, "original_exception") is inner + assert error.original_exception is inner assert "Caused by:" in str(error) assert "inner" in str(error) @@ -173,7 +179,7 @@ def test_original_exception_constructor_argument_sets_cause() -> None: with pytest.raises(AirbyteCloudError) as exc_info: raise error from replacement - assert getattr(exc_info.value, "original_exception") is replacement + assert exc_info.value.original_exception is replacement assert "Caused by:" in str(exc_info.value) assert "replacement" in str(exc_info.value) @@ -181,10 +187,31 @@ def test_original_exception_constructor_argument_sets_cause() -> None: def test_original_exception_is_none_without_a_cause() -> None: error = AirbyteCloudError(message="m") - assert getattr(error, "original_exception") is None + assert error.original_exception is None assert "Caused by" not in str(error) +def test_external_dataclass_subclass_with_post_init_accepts_original_exception() -> ( + None +): + @dataclass + class ExternalAirbyteError(AirbyteLibError): + post_init_ran: bool = False + + def __post_init__(self) -> None: + self.post_init_ran = True + + without_cause = ExternalAirbyteError(message="m") + assert without_cause.post_init_ran + assert without_cause.original_exception is None + + inner = ValueError("inner") + with_cause = ExternalAirbyteError(message="m", original_exception=inner) + assert with_cause.post_init_ran + assert with_cause.__cause__ is inner + assert with_cause.original_exception is inner + + def test_original_exception_constructor_argument_survives_subclass_post_init() -> None: inner = ValueError("inner") error = AirbyteNoCloudCredentialsError( @@ -194,16 +221,44 @@ def test_original_exception_constructor_argument_survives_subclass_post_init() - assert error.guidance == "guidance" assert error.__cause__ is inner - assert getattr(error, "original_exception") is inner + assert error.original_exception is inner -def test_sanitize_error_walks_raise_from_chain() -> None: - inner = AirbyteCloudError(message="inner") +def test_sanitize_error_does_not_walk_unopted_raise_from_cause() -> None: + inner = ValueError("password=hunter2") + + with pytest.raises(AirbyteCloudError) as exc_info: + raise AirbyteCloudError(message="outer") from inner + + error = exc_info.value + assert "hunter2" not in _sanitize_error(error) + assert "hunter2" not in str(error) + + +def test_sanitize_error_does_not_find_log_file_through_unopted_cause( + tmp_path: Path, +) -> None: + class ErrorWithLogFile(Exception): + def __init__(self, log_file: Path) -> None: + self.log_file = log_file + + log_file = tmp_path / "connector.log" + log_file.write_text( + '{"type":"TRACE","trace":{"error":{"internal_message":"cause detail"}}}', + encoding="utf-8", + ) + inner = ErrorWithLogFile(log_file) with pytest.raises(AirbyteCloudError) as exc_info: raise AirbyteCloudError(message="outer") from inner - assert _sanitize_error(exc_info.value) == "AirbyteCloudError: inner" + assert _extract_trace_error_from_log(exc_info.value) is None + + +def test_sanitize_error_walks_raise_from_chain() -> None: + inner = AirbyteCloudError(message="inner") + error = AirbyteCloudError(message="outer", original_exception=inner) + assert _sanitize_error(error) == "AirbyteCloudError: inner" if __name__ == "__main__": From 37377fe37bd10f3a8f4846296b5f5d8a20261c79 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:47:16 +0000 Subject: [PATCH 3/3] fix(exceptions): keep causes out of repr Co-Authored-By: AJ Steers --- airbyte/exceptions.py | 6 +++--- tests/unit_tests/test_exceptions.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/airbyte/exceptions.py b/airbyte/exceptions.py index c895a9d18..8d19e8217 100644 --- a/airbyte/exceptions.py +++ b/airbyte/exceptions.py @@ -39,7 +39,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from textwrap import indent from typing import TYPE_CHECKING, Any, Protocol, overload @@ -87,7 +87,7 @@ def __get__( return instance.__cause__ def __set__(self, instance: AirbyteLibError, value: BaseException | None) -> None: - if value is None: + if value is None or value is self: instance.__dict__["_original_exception_opt_in"] = False return @@ -109,7 +109,7 @@ class AirbyteLibError(Exception): print_full_log: bool = AIRBYTE_PRINT_FULL_ERROR_LOGS context: dict[str, Any] | None = None message: str | None = None - original_exception: _CauseDescriptor = _CauseDescriptor() + original_exception: _CauseDescriptor = field(default=_CauseDescriptor(), repr=False) def get_message(self) -> str: """Return the best description for the exception. diff --git a/tests/unit_tests/test_exceptions.py b/tests/unit_tests/test_exceptions.py index b6984067c..c87c6aae7 100644 --- a/tests/unit_tests/test_exceptions.py +++ b/tests/unit_tests/test_exceptions.py @@ -191,6 +191,18 @@ def test_original_exception_is_none_without_a_cause() -> None: assert "Caused by" not in str(error) +@pytest.mark.parametrize("error_class", [AirbyteCloudError, AirbyteLibError]) +def test_plain_raise_from_cause_is_not_in_repr( + error_class: type[AirbyteLibError], +) -> None: + assert error_class.original_exception is None + + with pytest.raises(error_class) as exc_info: + raise error_class(message="m") from ValueError("password=hunter2") + + assert "hunter2" not in repr(exc_info.value) + + def test_external_dataclass_subclass_with_post_init_accepts_original_exception() -> ( None ):