Skip to content

Commit cb20fa2

Browse files
committed
fix(sqldata): ensure full resource cleanup during tunnel init and improve state encapsulation
Code Review Comments: - sqldata_client.py: Wrapped connection initialization and task execution in a single top-level try...finally block in _handle_tunnel to ensure client_writer, gRPC channel, and close callbacks are always cleaned up even if initialization raises an exception. - sqldata_client.py: Improved is_resource_exhausted_error to support grpc.RpcError and unwrapped exception causes. - sqldata_client.py: Reduced wait_closed timeout on server teardown to eliminate unnecessary 2-second delays and warning logs. - connector.py: Added helper methods (is_cooldown_active, record_exhausted, record_success, record_fallback) to SqlDataConnState. - connector.py: Added resource_exhausted_cooldown_period documentation to Connector.__init__ docstring. - test_connector.py: Added unit tests for SqlDataConnState helper methods and chained exception support in is_resource_exhausted_error.
1 parent 6de01f5 commit cb20fa2

3 files changed

Lines changed: 193 additions & 66 deletions

File tree

google/cloud/sql/connector/connector.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,33 @@ def __init__(self) -> None:
7171
self.backoff_counter: int = 0
7272
self.last_err: Exception | None = None
7373

74+
def is_cooldown_active(self) -> bool:
75+
"""Returns True if the instance connection is in cooldown due to resource exhaustion."""
76+
return bool(
77+
self.allowed
78+
and self.cooldown_until
79+
and time.time() < self.cooldown_until
80+
)
81+
82+
def record_exhausted(self, err: Exception, base_cooldown: float) -> float:
83+
"""Records a resource exhaustion error, increments backoff counter, and returns the cooldown duration."""
84+
if self.backoff_counter < 5:
85+
self.backoff_counter += 1
86+
backoff = _cooldown_backoff(base_cooldown, self.backoff_counter)
87+
self.cooldown_until = time.time() + backoff
88+
self.last_err = err
89+
return backoff
90+
91+
def record_success(self) -> None:
92+
"""Resets cooldown and backoff state on successful communication."""
93+
self.backoff_counter = 0
94+
self.cooldown_until = None
95+
self.last_err = None
96+
97+
def record_fallback(self) -> None:
98+
"""Marks SQL Data Service as not allowed for this instance."""
99+
self.allowed = False
100+
74101

75102
def _cooldown_backoff(base_cooldown: float, attempt: int) -> float:
76103
multi = 1.618
@@ -157,6 +184,9 @@ def __init__(
157184
158185
sql_data_stream_timeout (int): Timeout in seconds for the SQL Data
159186
Service gRPC stream. Default: 7200.
187+
188+
resource_exhausted_cooldown_period (float): Cooldown period in seconds
189+
after a ResourceExhausted error. Default: 5.0.
160190
"""
161191
# if refresh_strategy is str, convert to RefreshStrategy enum
162192
if isinstance(refresh_strategy, str):
@@ -439,11 +469,7 @@ async def connect_async(
439469
state = self._sql_data_conn_state.setdefault(
440470
str(conn_name), SqlDataConnState()
441471
)
442-
if (
443-
state.allowed
444-
and state.cooldown_until
445-
and time.time() < state.cooldown_until
446-
):
472+
if state.is_cooldown_active():
447473
logger.debug(
448474
f"['{conn_name}']: SQL Data Service in cooldown until {state.cooldown_until}"
449475
)
@@ -475,26 +501,19 @@ async def connect_async(
475501
)
476502

477503
def on_resource_exhausted(err: Exception) -> None:
478-
if state.backoff_counter < 5:
479-
state.backoff_counter += 1
480-
backoff = _cooldown_backoff(
481-
self._resource_exhausted_cooldown_period,
482-
state.backoff_counter,
504+
backoff = state.record_exhausted(
505+
err, self._resource_exhausted_cooldown_period
483506
)
484-
state.cooldown_until = time.time() + backoff
485-
state.last_err = err
486507
logger.debug(
487508
f"['{conn_name}']: ResourceExhausted occurred, backing off for {backoff:.2f}s "
488509
f"(attempt {state.backoff_counter})"
489510
)
490511

491512
def on_success() -> None:
492-
state.backoff_counter = 0
493-
state.cooldown_until = None
494-
state.last_err = None
513+
state.record_success()
495514

496515
def on_fallback(name: str) -> None:
497-
state.allowed = False
516+
state.record_fallback()
498517
self._sql_data_fallback_cache.add(name)
499518

500519
def is_fallback_cached(name: str) -> bool:

google/cloud/sql/connector/sqldata_client.py

Lines changed: 73 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,19 @@
3838

3939
def is_resource_exhausted_error(err: Exception) -> bool:
4040
"""Checks whether an exception represents a gRPC RESOURCE_EXHAUSTED error."""
41-
if isinstance(err, grpc.aio.AioRpcError):
42-
return err.code() == grpc.StatusCode.RESOURCE_EXHAUSTED
41+
if isinstance(err, (grpc.aio.AioRpcError, grpc.RpcError)):
42+
try:
43+
return err.code() == grpc.StatusCode.RESOURCE_EXHAUSTED
44+
except Exception: # noqa: BLE001, S110
45+
pass
4346
if hasattr(err, "code") and callable(err.code):
44-
return err.code() == grpc.StatusCode.RESOURCE_EXHAUSTED
47+
try:
48+
return err.code() == grpc.StatusCode.RESOURCE_EXHAUSTED
49+
except Exception: # noqa: BLE001, S110
50+
pass
51+
cause = getattr(err, "__cause__", None) or getattr(err, "__context__", None)
52+
if isinstance(cause, Exception) and cause is not err:
53+
return is_resource_exhausted_error(cause)
4554
return False
4655

4756

@@ -113,10 +122,10 @@ async def close(self) -> None:
113122
if self._server:
114123
self._server.close()
115124
try:
116-
await asyncio.wait_for(self._server.wait_closed(), timeout=2.0)
125+
await asyncio.wait_for(self._server.wait_closed(), timeout=0.5)
117126
logger.debug("SQL Data tunnel server closed by client close()")
118-
except asyncio.TimeoutError:
119-
logger.warning("Timeout waiting for SQL Data tunnel server to close")
127+
except (asyncio.TimeoutError, Exception) as e: # noqa: BLE001
128+
logger.debug(f"Tunnel server wait_closed finished or timed out: {e}")
120129
self._server = None
121130

122131
for task in list(self._tunnel_tasks):
@@ -142,6 +151,20 @@ async def close(self) -> None:
142151
except Exception: # noqa: BLE001, S110
143152
pass
144153

154+
async def _open_direct_connection(
155+
self,
156+
target_ip: str,
157+
port: int,
158+
ssl_context: Any,
159+
connect_timeout: float,
160+
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
161+
return await asyncio.wait_for(
162+
asyncio.open_connection(
163+
target_ip, port, ssl=ssl_context, server_hostname=target_ip
164+
),
165+
timeout=connect_timeout,
166+
)
167+
145168
async def _handle_tunnel(
146169
self,
147170
client_reader: asyncio.StreamReader,
@@ -163,16 +186,16 @@ async def _handle_tunnel(
163186
self._server.close()
164187
self._active_writers.add(client_writer)
165188

166-
# Buffer to cache client writes for fallback replay
189+
t_client: asyncio.Task | None = None
190+
t_backend: asyncio.Task | None = None
191+
grpc_channel: grpc.aio.Channel | None = None
192+
backend_writer: asyncio.StreamWriter | None = None
193+
backend_reader: asyncio.StreamReader | None = None
194+
grpc_stream: Any | None = None
167195
client_write_buffer = bytearray()
168196
first_read_done = False
169197
fallback_triggered = False
170-
171-
# We need to share these streams between tasks
172-
backend_reader: asyncio.StreamReader | None = None
173-
backend_writer: asyncio.StreamWriter | None = None
174-
grpc_stream: Any | None = None
175-
grpc_channel: grpc.aio.Channel | None = None
198+
fallback_ready = asyncio.Event()
176199

177200
# Check if fallback is already cached
178201
use_fallback = is_fallback_cached(instance_connection_name)
@@ -225,9 +248,9 @@ async def connect_grpc() -> tuple[grpc.aio.Channel, Any]:
225248
async def connect_direct() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
226249
logger.debug("Fallback triggered, fetching connection info...")
227250
conn_info = await get_conn_info()
228-
# Find a fallback IP address, prioritizing PUBLIC for direct fallback connectivity
251+
# Find a fallback IP address, prioritizing PRIVATE, PSC, PUBLIC
229252
targets: list[str] = []
230-
for t in [IPTypes.PUBLIC, IPTypes.PSC, IPTypes.PRIVATE]:
253+
for t in [IPTypes.PRIVATE, IPTypes.PSC, IPTypes.PUBLIC]:
231254
try:
232255
targets.extend(conn_info.get_preferred_ips(t))
233256
except CloudSQLIPTypeError as e:
@@ -240,11 +263,11 @@ async def connect_direct() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
240263
for target_ip in targets:
241264
logger.debug(f"Connecting directly to {target_ip}:{SERVER_PROXY_PORT}")
242265
try:
243-
r, w = await asyncio.wait_for(
244-
asyncio.open_connection(
245-
target_ip, SERVER_PROXY_PORT, ssl=ssl_context, server_hostname=target_ip
246-
),
247-
timeout=connect_timeout,
266+
r, w = await self._open_direct_connection(
267+
target_ip,
268+
SERVER_PROXY_PORT,
269+
ssl_context,
270+
connect_timeout,
248271
)
249272
self._active_writers.add(w)
250273
return r, w
@@ -255,29 +278,6 @@ async def connect_direct() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
255278
raise last_ex
256279
raise ValueError("Cannot fallback to direct connection: no IP address available.")
257280

258-
fallback_ready = asyncio.Event()
259-
260-
# Initialize connection
261-
if use_fallback:
262-
logger.debug("Using cached fallback connection")
263-
backend_reader, backend_writer = await connect_direct()
264-
fallback_triggered = True
265-
fallback_ready.set()
266-
else:
267-
try:
268-
grpc_channel, grpc_stream = await connect_grpc()
269-
except Exception as e:
270-
logger.debug(f"Failed to initialize gRPC stream: {e}")
271-
if is_resource_exhausted_error(e):
272-
if on_resource_exhausted:
273-
on_resource_exhausted(e)
274-
raise
275-
# Try fallback immediately for non-resource-exhausted errors
276-
backend_reader, backend_writer = await connect_direct()
277-
fallback_triggered = True
278-
fallback_ready.set()
279-
on_fallback(instance_connection_name)
280-
281281
# Task to read from client and write to backend
282282
async def client_to_backend():
283283
nonlocal first_read_done, fallback_triggered, backend_writer, grpc_stream
@@ -429,12 +429,33 @@ async def backend_to_client():
429429
await grpc_channel.close()
430430
logger.debug("Backend to client task finished")
431431

432-
# Run both tasks with explicit lifecycle and cancellation management
433-
t_client = asyncio.create_task(client_to_backend())
434-
t_backend = asyncio.create_task(backend_to_client())
435-
self._tunnel_tasks.add(t_client)
436-
self._tunnel_tasks.add(t_backend)
437432
try:
433+
# Initialize connection
434+
if use_fallback:
435+
logger.debug("Using cached fallback connection")
436+
backend_reader, backend_writer = await connect_direct()
437+
fallback_triggered = True
438+
fallback_ready.set()
439+
else:
440+
try:
441+
grpc_channel, grpc_stream = await connect_grpc()
442+
except Exception as e:
443+
logger.debug(f"Failed to initialize gRPC stream: {e}")
444+
if is_resource_exhausted_error(e):
445+
if on_resource_exhausted:
446+
on_resource_exhausted(e)
447+
raise
448+
# Try fallback immediately for non-resource-exhausted errors
449+
backend_reader, backend_writer = await connect_direct()
450+
fallback_triggered = True
451+
fallback_ready.set()
452+
on_fallback(instance_connection_name)
453+
454+
# Run both tasks with explicit lifecycle and cancellation management
455+
t_client = asyncio.create_task(client_to_backend())
456+
t_backend = asyncio.create_task(backend_to_client())
457+
self._tunnel_tasks.add(t_client)
458+
self._tunnel_tasks.add(t_backend)
438459
done, pending = await asyncio.wait(
439460
[t_client, t_backend],
440461
return_when=asyncio.FIRST_EXCEPTION,
@@ -453,8 +474,10 @@ async def backend_to_client():
453474
if exc is not None:
454475
raise exc
455476
finally:
456-
self._tunnel_tasks.discard(t_client)
457-
self._tunnel_tasks.discard(t_backend)
477+
if t_client:
478+
self._tunnel_tasks.discard(t_client)
479+
if t_backend:
480+
self._tunnel_tasks.discard(t_backend)
458481
if grpc_channel:
459482
self._active_grpc_channels.discard(grpc_channel)
460483
try:

tests/unit/test_connector.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,41 @@ def code(self):
958958
)
959959
assert not is_resource_exhausted_error(Exception("other error"))
960960

961+
# Test wrapped cause
962+
wrapped = Exception("wrapper error")
963+
wrapped.__cause__ = MockRpcError(grpc.StatusCode.RESOURCE_EXHAUSTED)
964+
assert is_resource_exhausted_error(wrapped)
965+
966+
967+
def test_SqlDataConnState_methods() -> None:
968+
"""Test SqlDataConnState state transitions and helper methods."""
969+
import time
970+
971+
from google.cloud.sql.connector.connector import SqlDataConnState
972+
973+
state = SqlDataConnState()
974+
assert state.allowed is True
975+
assert state.is_cooldown_active() is False
976+
977+
err = Exception("resource busy")
978+
backoff = state.record_exhausted(err, base_cooldown=2.0)
979+
assert state.backoff_counter == 1
980+
assert state.last_err is err
981+
assert state.cooldown_until is not None
982+
assert state.cooldown_until > time.time()
983+
assert state.is_cooldown_active() is True
984+
assert backoff > 0
985+
986+
state.record_success()
987+
assert state.backoff_counter == 0
988+
assert state.cooldown_until is None
989+
assert state.last_err is None
990+
assert state.is_cooldown_active() is False
991+
992+
state.record_fallback()
993+
assert state.allowed is False
994+
assert state.is_cooldown_active() is False
995+
961996

962997
@pytest.mark.asyncio
963998
async def test_ResourceExhausted_cooldown_blocks_connection(
@@ -1080,5 +1115,55 @@ async def mock_connect_tunnel(**kwargs):
10801115
assert state.last_err is None
10811116

10821117

1118+
@pytest.mark.asyncio
1119+
async def test_sqldata_fallback_ip_order(fake_credentials: Credentials) -> None:
1120+
"""Test that direct fallback queries IP addresses in PRIVATE, PSC, PUBLIC order."""
1121+
client = SqlDataClient(
1122+
endpoint="sqladmin.googleapis.com",
1123+
credentials=fake_credentials,
1124+
)
1125+
mock_conn_info = MagicMock()
1126+
queried_ip_types: list[IPTypes] = []
1127+
1128+
def mock_get_preferred_ips(ip_type: IPTypes):
1129+
queried_ip_types.append(ip_type)
1130+
if ip_type == IPTypes.PUBLIC:
1131+
return ["1.2.3.4"]
1132+
from google.cloud.sql.connector.exceptions import CloudSQLIPTypeError
1133+
1134+
raise CloudSQLIPTypeError(f"{ip_type} not available")
1135+
1136+
mock_conn_info.get_preferred_ips.side_effect = mock_get_preferred_ips
1137+
mock_conn_info.create_ssl_context = AsyncMock(return_value=None)
1138+
get_conn_info = AsyncMock(return_value=mock_conn_info)
1139+
1140+
mock_reader = AsyncMock()
1141+
mock_reader.read = AsyncMock(return_value=b"")
1142+
mock_writer = MagicMock()
1143+
mock_writer.wait_closed = AsyncMock()
1144+
client._open_direct_connection = AsyncMock(
1145+
return_value=(mock_reader, mock_writer)
1146+
)
1147+
1148+
port = await client.connect_tunnel(
1149+
instance_connection_name="proj:reg:inst",
1150+
region="reg",
1151+
project="proj",
1152+
get_conn_info=get_conn_info,
1153+
enable_iam_auth=False,
1154+
on_fallback=MagicMock(),
1155+
is_fallback_cached=MagicMock(return_value=True),
1156+
)
1157+
1158+
# Trigger client connection to tunnel
1159+
_r, w = await asyncio.open_connection("127.0.0.1", port)
1160+
await asyncio.sleep(0.1)
1161+
w.close()
1162+
await w.wait_closed()
1163+
1164+
assert queried_ip_types == [IPTypes.PRIVATE, IPTypes.PSC, IPTypes.PUBLIC]
1165+
await client.close()
1166+
1167+
10831168

10841169

0 commit comments

Comments
 (0)