Skip to content

Commit 9434a4b

Browse files
fix: preserve exception cause
1 parent f8bf6f9 commit 9434a4b

File tree

2 files changed

+159
-2
lines changed

2 files changed

+159
-2
lines changed

google/api_core/retry/retry_base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,10 @@ def build_retry_error(
164164
src_exc,
165165
)
166166
elif exc_list:
167-
# return most recent exception encountered
168-
return exc_list[-1], None
167+
# return most recent exception encountered and its cause
168+
final_exc = exc_list[-1]
169+
cause = getattr(final_exc, '__cause__', None)
170+
return final_exc, cause
169171
else:
170172
# no exceptions were given in exc_list. Raise generic RetryError
171173
return exceptions.RetryError("Unknown error", None), None
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Test for exception chain preservation bug in retry mechanism.
17+
18+
This test demonstrates that the retry mechanism should preserve explicit
19+
exception chaining (raise ... from ...) when re-raising non-retryable errors.
20+
"""
21+
22+
import pytest
23+
24+
from google.api_core import exceptions
25+
from google.api_core import retry_async
26+
27+
28+
class CustomApplicationError(Exception):
29+
"""Custom exception for testing exception chaining."""
30+
pass
31+
32+
33+
@pytest.mark.asyncio
34+
async def test_exception_chain_preserved_with_retry_decorator():
35+
"""
36+
Test that explicit exception chaining is preserved when a non-retryable
37+
exception is raised through the retry mechanism.
38+
39+
This test will FAIL with the current bug because __cause__ is cleared
40+
when the retry mechanism re-raises with "raise final_exc from None".
41+
42+
This test will PASS once the bug is fixed to preserve __cause__.
43+
"""
44+
# Create a decorated async function that raises a chained exception
45+
@retry_async.AsyncRetry(
46+
predicate=retry_async.if_exception_type(exceptions.InternalServerError),
47+
initial=0.1,
48+
multiplier=1,
49+
maximum=0.2
50+
)
51+
async def function_with_chained_exception():
52+
"""Function that raises a non-retryable exception with explicit chaining."""
53+
try:
54+
# Raise the original exception (this would be retryable, but we catch it)
55+
raise exceptions.Unauthenticated("401 Invalid authentication credentials")
56+
except exceptions.Unauthenticated as original_exc:
57+
# Raise a non-retryable exception with explicit chaining
58+
# The retry decorator only retries InternalServerError, so this will
59+
# be immediately re-raised as non-retryable
60+
raise CustomApplicationError("Access denied due to authentication failure") from original_exc
61+
62+
# Execute the function and catch the exception
63+
with pytest.raises(CustomApplicationError) as exc_info:
64+
await function_with_chained_exception()
65+
66+
caught_exception = exc_info.value
67+
68+
# Assert that the exception chain is preserved
69+
# BUG: With the current implementation, __cause__ will be None
70+
# FIX: Once fixed, __cause__ should point to the Unauthenticated exception
71+
assert caught_exception.__cause__ is not None, (
72+
"Exception chain was lost! The __cause__ should be preserved when "
73+
"re-raising non-retryable exceptions through the retry mechanism."
74+
)
75+
76+
assert isinstance(caught_exception.__cause__, exceptions.Unauthenticated), (
77+
f"Expected __cause__ to be Unauthenticated, got {type(caught_exception.__cause__)}"
78+
)
79+
80+
assert "Invalid authentication credentials" in str(caught_exception.__cause__), (
81+
f"Expected original exception message in: {str(caught_exception.__cause__)}"
82+
)
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_exception_chain_preserved_without_retry_decorator():
87+
"""
88+
Control test: verify that exception chaining works correctly WITHOUT
89+
the retry decorator.
90+
91+
This test should PASS both before and after the fix, demonstrating
92+
that the issue is specific to the retry mechanism.
93+
"""
94+
async def function_without_retry():
95+
"""Function without retry decorator - exception chain should work normally."""
96+
try:
97+
raise exceptions.Unauthenticated("401 Invalid authentication credentials")
98+
except exceptions.Unauthenticated as original_exc:
99+
raise CustomApplicationError("Access denied due to authentication failure") from original_exc
100+
101+
with pytest.raises(CustomApplicationError) as exc_info:
102+
await function_without_retry()
103+
104+
caught_exception = exc_info.value
105+
106+
# This should always pass - exception chaining works normally without retry
107+
assert caught_exception.__cause__ is not None
108+
assert isinstance(caught_exception.__cause__, exceptions.Unauthenticated)
109+
assert "Invalid authentication credentials" in str(caught_exception.__cause__)
110+
111+
112+
@pytest.mark.asyncio
113+
async def test_nested_exception_chain_preserved():
114+
"""
115+
Test that deeply nested exception chains are preserved.
116+
117+
This tests a more complex scenario where there are multiple levels
118+
of exception chaining: A -> B -> C
119+
"""
120+
@retry_async.AsyncRetry(
121+
predicate=retry_async.if_exception_type(exceptions.InternalServerError),
122+
initial=0.1,
123+
multiplier=1,
124+
maximum=0.2
125+
)
126+
async def function_with_nested_chain():
127+
"""Function with multiple levels of exception chaining."""
128+
try:
129+
try:
130+
# Level 1: Original error
131+
raise ValueError("Invalid configuration value")
132+
except ValueError as level1_exc:
133+
# Level 2: Wrap in auth error
134+
raise exceptions.Unauthenticated("Auth failed") from level1_exc
135+
except exceptions.Unauthenticated as level2_exc:
136+
# Level 3: Wrap in custom error (non-retryable)
137+
raise CustomApplicationError("Application error") from level2_exc
138+
139+
with pytest.raises(CustomApplicationError) as exc_info:
140+
await function_with_nested_chain()
141+
142+
caught_exception = exc_info.value
143+
144+
# Assert the immediate cause is preserved
145+
assert caught_exception.__cause__ is not None, (
146+
"First level of exception chain was lost"
147+
)
148+
assert isinstance(caught_exception.__cause__, exceptions.Unauthenticated)
149+
150+
# Assert the nested cause is also preserved
151+
assert caught_exception.__cause__.__cause__ is not None, (
152+
"Nested exception chain was lost"
153+
)
154+
assert isinstance(caught_exception.__cause__.__cause__, ValueError)
155+
assert str(caught_exception.__cause__.__cause__) == "Invalid configuration value"

0 commit comments

Comments
 (0)