Skip to content

Commit b6abaed

Browse files
devin-ai-integration[bot]bot_apksophiecuiy
authored
fix: update AirbyteTracedException.__str__ to show user-facing message (AI-Triage PR) (#927)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: bot_apk <apk@cognition.ai> Co-authored-by: sophie.cui@airbyte.io <sophie.cui@airbyte.io>
1 parent 7f41401 commit b6abaed

File tree

3 files changed

+188
-20
lines changed

3 files changed

+188
-20
lines changed

airbyte_cdk/sources/streams/http/http_client.py

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -82,23 +82,6 @@ def monkey_patched_get_item(self, key): # type: ignore # this interface is a co
8282
requests_cache.SQLiteDict.__getitem__ = monkey_patched_get_item # type: ignore # see the method doc for more information
8383

8484

85-
class MessageRepresentationAirbyteTracedErrors(AirbyteTracedException):
86-
"""
87-
Before the migration to the HttpClient in low-code, the exception raised was
88-
[ReadException](https://github.com/airbytehq/airbyte/blob/8fdd9818ec16e653ba3dd2b167a74b7c07459861/airbyte-cdk/python/airbyte_cdk/sources/declarative/requesters/http_requester.py#L566).
89-
This has been moved to a AirbyteTracedException. The printing on this is questionable (AirbyteTracedException string representation
90-
shows the internal_message and not the message). We have already discussed moving the AirbyteTracedException string representation to
91-
`message` but the impact is unclear and hard to quantify so we will do it here only for now.
92-
"""
93-
94-
def __str__(self) -> str:
95-
if self.message:
96-
return self.message
97-
elif self.internal_message:
98-
return self.internal_message
99-
return ""
100-
101-
10285
class HttpClient:
10386
_DEFAULT_MAX_RETRY: int = 5
10487
_DEFAULT_MAX_TIME: int = 60 * 10
@@ -311,7 +294,7 @@ def _send_with_retry(
311294
return response
312295
except BaseBackoffException as e:
313296
self._logger.error(f"Retries exhausted with backoff exception.", exc_info=True)
314-
raise MessageRepresentationAirbyteTracedErrors(
297+
raise AirbyteTracedException(
315298
internal_message=f"Exhausted available request attempts. Exception: {e}",
316299
message=f"Exhausted available request attempts. Please see logs for more details. Exception: {e}",
317300
failure_type=e.failure_type or FailureType.system_error,
@@ -495,7 +478,7 @@ def _handle_error_resolution(
495478
# ensure the exception message is emitted before raised
496479
self._logger.error(error_message)
497480

498-
raise MessageRepresentationAirbyteTracedErrors(
481+
raise AirbyteTracedException(
499482
internal_message=error_message,
500483
message=error_resolution.error_message or error_message,
501484
failure_type=error_resolution.failure_type,

airbyte_cdk/utils/traced_exception.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ def __init__(
4949
self._stream_descriptor = stream_descriptor
5050
super().__init__(internal_message)
5151

52+
def __str__(self) -> str:
53+
"""Return the user-facing message, falling back to internal_message."""
54+
if self.message is not None:
55+
return self.message
56+
if self.internal_message is not None:
57+
return self.internal_message
58+
return ""
59+
5260
def as_airbyte_message(
5361
self, stream_descriptor: Optional[StreamDescriptor] = None
5462
) -> AirbyteMessage:
@@ -112,8 +120,15 @@ def from_exception(
112120
:param exc: the exception that caused the error
113121
:param stream_descriptor: describe the stream from which the exception comes from
114122
"""
123+
if isinstance(exc, AirbyteTracedException):
124+
internal_message = exc.internal_message
125+
# Preserve the original user-facing message if the caller didn't provide one
126+
if "message" not in kwargs:
127+
kwargs["message"] = exc.message
128+
else:
129+
internal_message = str(exc)
115130
return cls(
116-
internal_message=str(exc),
131+
internal_message=internal_message,
117132
exception=exc,
118133
stream_descriptor=stream_descriptor,
119134
*args,

unit_tests/utils/test_traced_exception.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,173 @@ def test_given_both_from_exception_and_as_sanitized_airbyte_message_with_stream_
166166
)
167167
message = traced_exc.as_sanitized_airbyte_message(stream_descriptor=_ANOTHER_STREAM_DESCRIPTOR)
168168
assert message.trace.error.stream_descriptor == _A_STREAM_DESCRIPTOR
169+
170+
171+
class TestAirbyteTracedExceptionStr:
172+
"""Tests proving that __str__ returns user-facing message instead of internal_message."""
173+
174+
def test_str_returns_user_facing_message_when_both_set(self) -> None:
175+
exc = AirbyteTracedException(
176+
internal_message="raw API error: 401 Unauthorized",
177+
message="Authentication credentials are invalid.",
178+
)
179+
assert str(exc) == "Authentication credentials are invalid."
180+
181+
def test_str_falls_back_to_internal_message_when_message_is_none(self) -> None:
182+
exc = AirbyteTracedException(internal_message="an internal error")
183+
assert str(exc) == "an internal error"
184+
185+
def test_str_returns_empty_string_when_both_none(self) -> None:
186+
exc = AirbyteTracedException()
187+
assert str(exc) == ""
188+
189+
def test_str_returns_message_when_internal_message_is_none(self) -> None:
190+
exc = AirbyteTracedException(message="A user-friendly error occurred.")
191+
assert str(exc) == "A user-friendly error occurred."
192+
193+
def test_str_used_in_fstring_returns_user_facing_message(self) -> None:
194+
exc = AirbyteTracedException(
195+
internal_message="internal detail",
196+
message="Connection timed out.",
197+
)
198+
assert f"Error: {exc}" == "Error: Connection timed out."
199+
200+
def test_str_used_in_logging_format_returns_user_facing_message(self) -> None:
201+
exc = AirbyteTracedException(
202+
internal_message="socket.timeout: read timed out",
203+
message="Request timed out.",
204+
)
205+
assert "Error: %s" % exc == "Error: Request timed out."
206+
207+
def test_args_still_contains_internal_message(self) -> None:
208+
"""Verify args[0] is still internal_message for traceback formatting."""
209+
exc = AirbyteTracedException(
210+
internal_message="internal detail",
211+
message="user-facing message",
212+
)
213+
assert exc.args[0] == "internal detail"
214+
215+
def test_str_on_subclass_inherits_behavior(self) -> None:
216+
"""Verify subclasses inherit the __str__ override without needing their own."""
217+
218+
class CustomTracedException(AirbyteTracedException):
219+
pass
220+
221+
exc = CustomTracedException(
222+
internal_message="raw error",
223+
message="User-friendly error.",
224+
)
225+
assert str(exc) == "User-friendly error."
226+
227+
def test_str_with_from_exception_factory(self) -> None:
228+
original = ValueError("original error")
229+
exc = AirbyteTracedException.from_exception(
230+
original, message="A validation error occurred."
231+
)
232+
assert str(exc) == "A validation error occurred."
233+
assert exc.internal_message == "original error"
234+
235+
def test_str_with_from_exception_without_message(self) -> None:
236+
original = RuntimeError("runtime failure")
237+
exc = AirbyteTracedException.from_exception(original)
238+
assert str(exc) == "runtime failure"
239+
240+
def test_stack_trace_uses_str_representation(self) -> None:
241+
"""Verify traceback one-liner uses __str__ (user-facing message)."""
242+
exc = AirbyteTracedException(
243+
internal_message="internal detail for traceback",
244+
message="User sees this.",
245+
)
246+
airbyte_message = exc.as_airbyte_message()
247+
assert "User sees this." in airbyte_message.trace.error.stack_trace
248+
249+
def test_str_with_empty_message_does_not_fall_back_to_internal_message(self) -> None:
250+
"""Explicit empty message should be respected and not replaced by internal_message."""
251+
exc = AirbyteTracedException(
252+
internal_message="an internal error that should not be shown to the user",
253+
message="",
254+
)
255+
assert str(exc) == ""
256+
257+
def test_internal_message_preserved_in_trace_error(self) -> None:
258+
"""Verify internal_message is still available in the trace error for debugging."""
259+
exc = AirbyteTracedException(
260+
internal_message="raw API error: 401",
261+
message="Authentication failed.",
262+
)
263+
airbyte_message = exc.as_airbyte_message()
264+
assert airbyte_message.trace.error.internal_message == "raw API error: 401"
265+
assert airbyte_message.trace.error.message == "Authentication failed."
266+
267+
268+
class TestFromExceptionPreservesFields:
269+
"""Tests proving that from_exception preserves both fields when wrapping an AirbyteTracedException."""
270+
271+
def test_from_exception_wrapping_traced_preserves_internal_message(self) -> None:
272+
"""When wrapping an AirbyteTracedException, internal_message should be taken directly, not via str()."""
273+
original = AirbyteTracedException(
274+
internal_message="raw API error: 401 Unauthorized",
275+
message="Authentication failed.",
276+
)
277+
wrapped = AirbyteTracedException.from_exception(original)
278+
assert wrapped.internal_message == "raw API error: 401 Unauthorized"
279+
280+
def test_from_exception_wrapping_traced_preserves_message(self) -> None:
281+
"""When wrapping an AirbyteTracedException without explicit message, the original's message is preserved."""
282+
original = AirbyteTracedException(
283+
internal_message="raw API error",
284+
message="User-friendly error.",
285+
)
286+
wrapped = AirbyteTracedException.from_exception(original)
287+
assert wrapped.message == "User-friendly error."
288+
289+
def test_from_exception_wrapping_traced_caller_message_overrides(self) -> None:
290+
"""When the caller provides an explicit message, it should override the original's message."""
291+
original = AirbyteTracedException(
292+
internal_message="raw API error",
293+
message="Original user message.",
294+
)
295+
wrapped = AirbyteTracedException.from_exception(original, message="Custom wrapper message.")
296+
assert wrapped.message == "Custom wrapper message."
297+
assert wrapped.internal_message == "raw API error"
298+
299+
def test_from_exception_wrapping_traced_with_only_message(self) -> None:
300+
"""When wrapping a traced exception that has message but no internal_message, both fields are preserved."""
301+
original = AirbyteTracedException(
302+
message="User error only.",
303+
)
304+
wrapped = AirbyteTracedException.from_exception(original)
305+
assert wrapped.internal_message is None
306+
assert wrapped.message == "User error only."
307+
308+
def test_from_exception_wrapping_traced_with_only_internal_message(self) -> None:
309+
"""When wrapping a traced exception that has only internal_message, it is preserved correctly."""
310+
original = AirbyteTracedException(
311+
internal_message="internal detail only",
312+
)
313+
wrapped = AirbyteTracedException.from_exception(original)
314+
assert wrapped.internal_message == "internal detail only"
315+
assert wrapped.message is None
316+
317+
def test_from_exception_wrapping_traced_with_neither_field(self) -> None:
318+
"""When wrapping a traced exception with no messages, both remain None."""
319+
original = AirbyteTracedException()
320+
wrapped = AirbyteTracedException.from_exception(original)
321+
assert wrapped.internal_message is None
322+
assert wrapped.message is None
323+
324+
def test_from_exception_wrapping_regular_exception_unchanged(self) -> None:
325+
"""Wrapping a regular exception should still use str(exc) for internal_message."""
326+
original = ValueError("some value error")
327+
wrapped = AirbyteTracedException.from_exception(original)
328+
assert wrapped.internal_message == "some value error"
329+
assert wrapped.message is None
330+
331+
def test_from_exception_wrapping_regular_exception_with_message(self) -> None:
332+
"""Wrapping a regular exception with explicit message kwarg still works."""
333+
original = RuntimeError("runtime failure")
334+
wrapped = AirbyteTracedException.from_exception(
335+
original, message="A runtime error occurred."
336+
)
337+
assert wrapped.internal_message == "runtime failure"
338+
assert wrapped.message == "A runtime error occurred."

0 commit comments

Comments
 (0)