cloud-sql-python-connector 1.22.0__py3-none-any.whl → 1.24.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,13 +17,17 @@ limitations under the License.
17
17
  from __future__ import annotations
18
18
 
19
19
  import asyncio
20
+ from collections.abc import Callable
20
21
  from functools import partial
21
22
  import logging
22
23
  import os
24
+ import random
23
25
  import socket
26
+ import ssl
24
27
  from threading import Thread
28
+ import time
25
29
  from types import TracebackType
26
- from typing import Any, Callable
30
+ from typing import Any
27
31
 
28
32
  import google.auth
29
33
  from google.auth.credentials import Credentials
@@ -35,16 +39,22 @@ from google.cloud.sql.connector import psycopg
35
39
  from google.cloud.sql.connector import pymysql
36
40
  from google.cloud.sql.connector import pytds
37
41
  from google.cloud.sql.connector.client import CloudSQLClient
42
+ from google.cloud.sql.connector.connection_info import ConnectionInfo
43
+ from google.cloud.sql.connector.connection_name import ConnectionName
38
44
  from google.cloud.sql.connector.enums import DriverMapping
39
45
  from google.cloud.sql.connector.enums import IPTypes
40
46
  from google.cloud.sql.connector.enums import RefreshStrategy
41
47
  from google.cloud.sql.connector.exceptions import ClosedConnectorError
42
48
  from google.cloud.sql.connector.exceptions import ConnectorLoopError
49
+ from google.cloud.sql.connector.exceptions import IncompatibleDriverError
50
+ from google.cloud.sql.connector.exceptions import ResourceExhaustedError
43
51
  from google.cloud.sql.connector.instance import RefreshAheadCache
44
52
  from google.cloud.sql.connector.lazy import LazyRefreshCache
45
53
  from google.cloud.sql.connector.monitored_cache import MonitoredCache
54
+ from google.cloud.sql.connector.postgres_connection import PostgresConnection
46
55
  from google.cloud.sql.connector.resolver import DefaultResolver
47
56
  from google.cloud.sql.connector.resolver import DnsResolver
57
+ from google.cloud.sql.connector.sqldata_client import SqlDataClient
48
58
  from google.cloud.sql.connector.utils import format_database_user
49
59
  from google.cloud.sql.connector.utils import generate_keys
50
60
 
@@ -57,6 +67,49 @@ _DEFAULT_UNIVERSE_DOMAIN = "googleapis.com"
57
67
  _SQLADMIN_HOST_TEMPLATE = "sqladmin.{universe_domain}"
58
68
 
59
69
 
70
+ class SqlDataConnState:
71
+ """Tracks connection state, fallback status, and resource exhaustion cooldown for SQL Data Service."""
72
+
73
+ def __init__(self) -> None:
74
+ self.allowed: bool = True
75
+ self.cooldown_until: float | None = None
76
+ self.backoff_counter: int = 0
77
+ self.last_err: Exception | None = None
78
+
79
+ def is_cooldown_active(self) -> bool:
80
+ """Returns True if the instance connection is in cooldown due to resource exhaustion."""
81
+ return bool(
82
+ self.allowed
83
+ and self.cooldown_until
84
+ and time.time() < self.cooldown_until
85
+ )
86
+
87
+ def record_exhausted(self, err: Exception, base_cooldown: float) -> float:
88
+ """Records a resource exhaustion error, increments backoff counter, and returns the cooldown duration."""
89
+ if self.backoff_counter < 5:
90
+ self.backoff_counter += 1
91
+ backoff = _cooldown_backoff(base_cooldown, self.backoff_counter)
92
+ self.cooldown_until = time.time() + backoff
93
+ self.last_err = err
94
+ return backoff
95
+
96
+ def record_success(self) -> None:
97
+ """Resets cooldown and backoff state on successful communication."""
98
+ self.backoff_counter = 0
99
+ self.cooldown_until = None
100
+ self.last_err = None
101
+
102
+ def record_fallback(self) -> None:
103
+ """Marks SQL Data Service as not allowed for this instance."""
104
+ self.allowed = False
105
+
106
+
107
+ def _cooldown_backoff(base_cooldown: float, attempt: int) -> float:
108
+ multi = 1.618
109
+ exp = float(attempt - 1) + random.random()
110
+ return base_cooldown * (multi**exp)
111
+
112
+
60
113
  class Connector:
61
114
  """Configure and create secure connections to Cloud SQL."""
62
115
 
@@ -74,14 +127,20 @@ class Connector:
74
127
  refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
75
128
  resolver: type[DefaultResolver | DnsResolver] = DefaultResolver,
76
129
  failover_period: int = 30,
130
+ sql_data_endpoint: str | None = None,
131
+ sql_data_stream_timeout: int = 7200,
132
+ resource_exhausted_cooldown_period: float = 5.0,
77
133
  ) -> None:
78
134
  """Initializes a Connector instance.
79
135
 
80
136
  Args:
81
137
  ip_type (str | IPTypes): The default IP address type used to connect to
82
138
  Cloud SQL instances. Can be one of the following:
83
- IPTypes.PUBLIC ("PUBLIC"), IPTypes.PRIVATE ("PRIVATE"), or
84
- IPTypes.PSC ("PSC"). Default: IPTypes.PUBLIC
139
+ IPTypes.PUBLIC ("PUBLIC"), IPTypes.PRIVATE ("PRIVATE"),
140
+ IPTypes.PSC ("PSC"), or IPTypes.SQL_DATA ("SQL_DATA").
141
+ Note: IPTypes.SQL_DATA is not currently supported with the
142
+ asyncpg driver.
143
+ Default: IPTypes.PUBLIC
85
144
 
86
145
  enable_iam_auth (bool): Enables automatic IAM database authentication
87
146
  (Postgres and MySQL) as the default authentication method for all
@@ -126,6 +185,16 @@ class Connector:
126
185
  attempt to check if a failover has occured for a given instance.
127
186
  Must be used with `resolver=DnsResolver` to have any effect.
128
187
  Default: 30
188
+
189
+ sql_data_endpoint (str | None): Endpoint host for SQL Data Service
190
+ calls. If not specified, derived from `sqladmin_api_endpoint`.
191
+ Default: "sqladmin.googleapis.com".
192
+
193
+ sql_data_stream_timeout (int): Timeout in seconds for the SQL Data
194
+ Service gRPC stream. Default: 7200.
195
+
196
+ resource_exhausted_cooldown_period (float): Cooldown period in seconds
197
+ after a ResourceExhausted error. Default: 5.0.
129
198
  """
130
199
  # if refresh_strategy is str, convert to RefreshStrategy enum
131
200
  if isinstance(refresh_strategy, str):
@@ -214,6 +283,22 @@ class Connector:
214
283
  "configured the universe domain explicitly, `googleapis.com` "
215
284
  "is the default."
216
285
  )
286
+ if sql_data_endpoint:
287
+ self._sql_data_endpoint = sql_data_endpoint
288
+ else:
289
+ self._sql_data_endpoint = (
290
+ self._sqladmin_api_endpoint.split("://", 1)[-1].split("/", 1)[0]
291
+ )
292
+ self._sql_data_stream_timeout = sql_data_stream_timeout
293
+ self._resource_exhausted_cooldown_period = (
294
+ resource_exhausted_cooldown_period
295
+ )
296
+ self._sql_data_fallback_cache: set[str] = set()
297
+ self._sql_data_conn_state: dict[str, SqlDataConnState] = {}
298
+ self._sqldata_clients: set[Any] = set()
299
+ self._sqldata_client: SqlDataClient | None = None
300
+
301
+
217
302
 
218
303
  @property
219
304
  def universe_domain(self) -> str:
@@ -260,6 +345,63 @@ class Connector:
260
345
  )
261
346
  return connect_future.result()
262
347
 
348
+ def _get_or_create_cache(
349
+ self,
350
+ conn_name: ConnectionName,
351
+ enable_iam_auth: bool,
352
+ ip_type: IPTypes | None = None,
353
+ ) -> MonitoredCache:
354
+ assert self._client is not None, "client must be initialized before creating cache"
355
+ assert self._keys is not None, "keys must be initialized before creating cache"
356
+ assert self._resolver is not None, "resolver must be initialized before creating cache"
357
+ if (str(conn_name), enable_iam_auth) in self._cache and not self._cache[
358
+ (str(conn_name), enable_iam_auth)
359
+ ].closed:
360
+ return self._cache[(str(conn_name), enable_iam_auth)]
361
+
362
+ effective_ip_type = ip_type if ip_type is not None else self._ip_type
363
+ dial_func = partial(
364
+ self._dial_probe_connection,
365
+ enable_iam_auth=enable_iam_auth,
366
+ ip_type=effective_ip_type,
367
+ )
368
+
369
+ if self._refresh_strategy == RefreshStrategy.LAZY:
370
+ logger.debug(
371
+ f"['{conn_name}']: Refresh strategy is set to lazy refresh"
372
+ )
373
+ cache: LazyRefreshCache | RefreshAheadCache = LazyRefreshCache(
374
+ conn_name,
375
+ self._client,
376
+ self._keys,
377
+ enable_iam_auth,
378
+ ip_type=effective_ip_type,
379
+ timeout=self._timeout,
380
+ dial_func=dial_func,
381
+ )
382
+ else:
383
+ logger.debug(
384
+ f"['{conn_name}']: Refresh strategy is set to backgound refresh"
385
+ )
386
+ cache = RefreshAheadCache(
387
+ conn_name,
388
+ self._client,
389
+ self._keys,
390
+ enable_iam_auth,
391
+ ip_type=effective_ip_type,
392
+ timeout=self._timeout,
393
+ dial_func=dial_func,
394
+ )
395
+ # wrap cache as a MonitoredCache
396
+ monitored_cache = MonitoredCache(
397
+ cache,
398
+ self._failover_period,
399
+ self._resolver,
400
+ )
401
+ logger.debug(f"['{conn_name}']: Connection info added to cache")
402
+ self._cache[(str(conn_name), enable_iam_auth)] = monitored_cache
403
+ return monitored_cache
404
+
263
405
  async def connect_async(
264
406
  self, instance_connection_string: str, driver: str, **kwargs: Any
265
407
  ) -> Any:
@@ -321,42 +463,15 @@ class Connector:
321
463
  if self._resolver is None:
322
464
  self._resolver = self._resolver_cls(client=self._client)
323
465
  enable_iam_auth = kwargs.pop("enable_iam_auth", self._enable_iam_auth)
466
+ ip_type = kwargs.pop("ip_type", self._ip_type)
467
+ if isinstance(ip_type, str):
468
+ ip_type = IPTypes._from_str(ip_type)
324
469
 
325
470
  conn_name = await self._resolver.resolve(instance_connection_string)
326
- # Cache entry must exist and not be closed
327
- if (str(conn_name), enable_iam_auth) in self._cache and not self._cache[
328
- (str(conn_name), enable_iam_auth)
329
- ].closed:
330
- monitored_cache = self._cache[(str(conn_name), enable_iam_auth)]
331
- else:
332
- if self._refresh_strategy == RefreshStrategy.LAZY:
333
- logger.debug(
334
- f"['{conn_name}']: Refresh strategy is set to lazy refresh"
335
- )
336
- cache: LazyRefreshCache | RefreshAheadCache = LazyRefreshCache(
337
- conn_name,
338
- self._client,
339
- self._keys,
340
- enable_iam_auth,
341
- )
342
- else:
343
- logger.debug(
344
- f"['{conn_name}']: Refresh strategy is set to backgound refresh"
345
- )
346
- cache = RefreshAheadCache(
347
- conn_name,
348
- self._client,
349
- self._keys,
350
- enable_iam_auth,
351
- )
352
- # wrap cache as a MonitoredCache
353
- monitored_cache = MonitoredCache(
354
- cache,
355
- self._failover_period,
356
- self._resolver,
471
+ if ip_type != IPTypes.SQL_DATA:
472
+ monitored_cache = self._get_or_create_cache(
473
+ conn_name, enable_iam_auth, ip_type
357
474
  )
358
- logger.debug(f"['{conn_name}']: Connection info added to cache")
359
- self._cache[(str(conn_name), enable_iam_auth)] = monitored_cache
360
475
 
361
476
  connect_func = {
362
477
  "pymysql": pymysql.connect,
@@ -371,11 +486,6 @@ class Connector:
371
486
  connector: Callable = connect_func[driver] # type: ignore
372
487
  except KeyError:
373
488
  raise KeyError(f"Driver '{driver}' is not supported.")
374
-
375
- ip_type = kwargs.pop("ip_type", self._ip_type)
376
- # if ip_type is str, convert to IPTypes enum
377
- if isinstance(ip_type, str):
378
- ip_type = IPTypes._from_str(ip_type)
379
489
  kwargs["timeout"] = kwargs.get("timeout", self._timeout)
380
490
 
381
491
  # Host and ssl options come from the certificates and metadata, so we don't
@@ -384,113 +494,206 @@ class Connector:
384
494
  kwargs.pop("ssl", None)
385
495
  kwargs.pop("port", None)
386
496
 
387
- # attempt to get connection info for Cloud SQL instance
497
+ # attempt to establish connection
388
498
  try:
389
- conn_info = await monitored_cache.connect_info()
390
- # validate driver matches intended database engine
391
- DriverMapping.validate_engine(driver, conn_info.database_version)
392
- preferred_ips = conn_info.get_preferred_ips(ip_type)
393
- except Exception:
394
- # with an error from Cloud SQL Admin API call or IP type, invalidate
395
- # the cache and re-raise the error
396
- await self._remove_cached(str(conn_name), enable_iam_auth)
397
- raise
398
-
399
- targets = []
400
- # If the connector is configured with a custom DNS name, attempt to use
401
- # that DNS name to connect to the instance. Fall back to the metadata IP
402
- # address if the DNS name does not resolve to an IP address.
403
- if conn_info.conn_name.domain_name and isinstance(self._resolver, DnsResolver):
404
- try:
405
- ips = await self._resolver.resolve_a_record(conn_info.conn_name.domain_name)
406
- if ips:
407
- targets.extend(ips)
499
+ if ip_type == IPTypes.SQL_DATA:
500
+ if driver in ASYNC_DRIVERS:
501
+ raise IncompatibleDriverError(
502
+ f"Driver '{driver}' is not supported with ip_type '{ip_type}'."
503
+ )
504
+ state = self._sql_data_conn_state.setdefault(
505
+ str(conn_name), SqlDataConnState()
506
+ )
507
+ if state.is_cooldown_active():
408
508
  logger.debug(
409
- f"['{instance_connection_string}']: Custom DNS name "
410
- f"'{conn_info.conn_name.domain_name}' resolved to '{ips}', "
411
- "using it to connect"
509
+ f"['{conn_name}']: SQL Data Service in cooldown until {state.cooldown_until}"
510
+ )
511
+ raise ResourceExhaustedError(
512
+ "cooldown active", str(conn_name), state.last_err
513
+ )
514
+
515
+ logger.debug(f"['{conn_name}']: Connecting via SQL Data Service tunnel")
516
+ if enable_iam_auth:
517
+ engine = DriverMapping[driver.upper()].value
518
+ formatted_user = format_database_user(
519
+ engine, kwargs["user"]
520
+ )
521
+ if formatted_user != kwargs["user"]:
522
+ logger.debug(
523
+ f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
524
+ )
525
+ kwargs["user"] = formatted_user
526
+
527
+ if self._sqldata_client is None:
528
+ sqldata_client = SqlDataClient(
529
+ endpoint=self._sql_data_endpoint,
530
+ credentials=self._credentials,
531
+ quota_project=self._quota_project,
532
+ timeout=self._sql_data_stream_timeout,
533
+ )
534
+ self._sqldata_client = sqldata_client
535
+ self._sqldata_clients.add(sqldata_client)
536
+ sqldata_client._on_close_callbacks.append(
537
+ lambda: self._sqldata_clients.discard(sqldata_client)
412
538
  )
413
539
  else:
540
+ sqldata_client = self._sqldata_client
541
+
542
+ def on_resource_exhausted(err: Exception) -> None:
543
+ backoff = state.record_exhausted(
544
+ err, self._resource_exhausted_cooldown_period
545
+ )
414
546
  logger.debug(
415
- f"['{instance_connection_string}']: Custom DNS name "
416
- f"'{conn_info.conn_name.domain_name}' resolved but returned no "
417
- f"entries, using '{preferred_ips}' from instance metadata"
547
+ f"['{conn_name}']: ResourceExhausted occurred, backing off for {backoff:.2f}s "
548
+ f"(attempt {state.backoff_counter})"
418
549
  )
419
- targets.extend(preferred_ips)
420
- except Exception as e: # noqa: BLE001
421
- logger.debug(
422
- f"['{instance_connection_string}']: Custom DNS name "
423
- f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
424
- f"address: {e}, using '{preferred_ips}' from instance metadata"
425
- )
426
- targets.extend(preferred_ips)
427
- else:
428
- targets.extend(preferred_ips)
429
550
 
430
- # format `user` param for automatic IAM database authn
431
- if enable_iam_auth:
432
- formatted_user = format_database_user(
433
- conn_info.database_version, kwargs["user"]
434
- )
435
- if formatted_user != kwargs["user"]:
436
- logger.debug(
437
- f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
551
+ def on_success() -> None:
552
+ state.record_success()
553
+
554
+ def on_fallback(name: str) -> None:
555
+ state.record_fallback()
556
+ self._sql_data_fallback_cache.add(name)
557
+
558
+ def is_fallback_cached(name: str) -> bool:
559
+ return not state.allowed or name in self._sql_data_fallback_cache
560
+
561
+ # Defer cache creation and connect_info call
562
+ async def get_conn_info():
563
+ cache = self._get_or_create_cache(conn_name, enable_iam_auth)
564
+ return await cache.connect_info()
565
+
566
+ sock = await sqldata_client.connect(
567
+ instance_connection_name=str(conn_name),
568
+ region=conn_name.region,
569
+ project=conn_name.project,
570
+ get_conn_info=get_conn_info,
571
+ enable_iam_auth=enable_iam_auth,
572
+ on_fallback=on_fallback,
573
+ is_fallback_cached=is_fallback_cached,
574
+ on_resource_exhausted=on_resource_exhausted,
575
+ on_success=on_success,
576
+ connect_timeout=kwargs.get("timeout", self._timeout),
438
577
  )
439
- kwargs["user"] = formatted_user
440
578
 
441
- try:
442
- last_ex = None
443
- for target_ip in targets:
444
- logger.debug(f"['{conn_info.conn_name}']: Connecting to {target_ip}:3307")
579
+ cache = None
580
+ if conn_name.domain_name:
581
+ cache = self._get_or_create_cache(conn_name, enable_iam_auth)
582
+ cache.sockets.append(sock)
583
+
584
+ connect_partial = partial(
585
+ connector,
586
+ "127.0.0.1",
587
+ sock,
588
+ **kwargs,
589
+ )
445
590
  try:
446
- # async drivers are unblocking and can be awaited directly
447
- if driver in ASYNC_DRIVERS:
448
- conn = await connector(
591
+ return await self._loop.run_in_executor(None, connect_partial)
592
+ except Exception:
593
+ sock.close()
594
+ if conn_name.domain_name and cache:
595
+ cache._purge_closed_sockets()
596
+ raise
597
+ else:
598
+ try:
599
+ conn_info = await monitored_cache.connect_info()
600
+ # validate driver matches intended database engine
601
+ DriverMapping.validate_engine(driver, conn_info.database_version)
602
+ preferred_ips = conn_info.get_preferred_ips(ip_type)
603
+ except Exception:
604
+ # with an error from Cloud SQL Admin API call or IP type, invalidate
605
+ # the cache and re-raise the error
606
+ await self._remove_cached(str(conn_name), enable_iam_auth)
607
+ raise
608
+
609
+ targets = await self._resolve_targets(conn_info, preferred_ips)
610
+ # format `user` param for automatic IAM database authn
611
+ if enable_iam_auth:
612
+ formatted_user = format_database_user(
613
+ conn_info.database_version, kwargs["user"]
614
+ )
615
+ if formatted_user != kwargs["user"]:
616
+ logger.debug(
617
+ f"['{instance_connection_string}']: Truncated IAM database username from {kwargs['user']} to {formatted_user}"
618
+ )
619
+ kwargs["user"] = formatted_user
620
+
621
+ last_ex = None
622
+ for target_ip in targets:
623
+ logger.debug(f"['{conn_info.conn_name}']: Connecting to {target_ip}:3307")
624
+ try:
625
+ # async drivers are unblocking and can be awaited directly
626
+ if driver in ASYNC_DRIVERS:
627
+ conn = await connector(
628
+ target_ip,
629
+ await conn_info.create_ssl_context(enable_iam_auth),
630
+ **kwargs,
631
+ )
632
+ if (
633
+ enable_iam_auth
634
+ and conn_info.database_version.startswith("POSTGRES")
635
+ ):
636
+ db_name = kwargs.get("database")
637
+ if db_name is None:
638
+ db_name = kwargs.get("db")
639
+ user = kwargs.get("user")
640
+ if user:
641
+ monitored_cache.record_principal(
642
+ str(user),
643
+ str(db_name) if db_name else str(user),
644
+ )
645
+ last_ex = None
646
+ return conn
647
+
648
+ # Create socket with SSLContext for sync drivers
649
+ ctx = await conn_info.create_ssl_context(enable_iam_auth)
650
+ raw_sock = socket.create_connection((target_ip, SERVER_PROXY_PORT))
651
+ sync_sock: socket.socket | ssl.SSLSocket | PostgresConnection
652
+ try:
653
+ sync_sock = ctx.wrap_socket(
654
+ raw_sock,
655
+ server_hostname=target_ip,
656
+ )
657
+ except Exception:
658
+ raw_sock.close()
659
+ raise
660
+
661
+ if (
662
+ enable_iam_auth
663
+ and conn_info.database_version.startswith("POSTGRES")
664
+ ):
665
+ sync_sock = PostgresConnection(
666
+ sync_sock,
667
+ on_startup=monitored_cache.record_principal,
668
+ )
669
+
670
+ # If this connection was opened using a domain name, then store it
671
+ # for later in case we need to forcibly close it on failover.
672
+ if conn_info.conn_name.domain_name:
673
+ monitored_cache.sockets.append(sync_sock)
674
+ # Synchronous drivers are blocking and run using executor
675
+ connect_partial = partial(
676
+ connector,
449
677
  target_ip,
450
- await conn_info.create_ssl_context(enable_iam_auth),
678
+ sync_sock,
451
679
  **kwargs,
452
680
  )
681
+ conn = await self._loop.run_in_executor(None, connect_partial)
453
682
  last_ex = None
454
683
  return conn
455
-
456
- # Create socket with SSLContext for sync drivers
457
- ctx = await conn_info.create_ssl_context(enable_iam_auth)
458
- raw_sock = socket.create_connection((target_ip, SERVER_PROXY_PORT))
459
- try:
460
- sock = ctx.wrap_socket(
461
- raw_sock,
462
- server_hostname=target_ip,
684
+ except Exception as e: # noqa: BLE001
685
+ logger.debug(
686
+ f"['{conn_info.conn_name}']: Connection to {target_ip} failed: {e}"
463
687
  )
464
- except Exception:
465
- raw_sock.close()
466
- raise
467
-
468
- # If this connection was opened using a domain name, then store it
469
- # for later in case we need to forcibly close it on failover.
470
- if conn_info.conn_name.domain_name:
471
- monitored_cache.sockets.append(sock)
472
- # Synchronous drivers are blocking and run using executor
473
- connect_partial = partial(
474
- connector,
475
- target_ip,
476
- sock,
477
- **kwargs,
478
- )
479
- conn = await self._loop.run_in_executor(None, connect_partial)
480
- last_ex = None
481
- return conn
482
- except Exception as e: # noqa: BLE001
483
- logger.debug(
484
- f"['{conn_info.conn_name}']: Connection to {target_ip} failed: {e}"
485
- )
486
- last_ex = e
487
-
488
- if last_ex:
489
- raise last_ex
688
+ last_ex = e
689
+ if last_ex:
690
+ raise last_ex
490
691
 
491
692
  except Exception:
492
693
  # with any exception, we attempt a force refresh, then throw the error
493
- await monitored_cache.force_refresh()
694
+ cached_entry = self._cache.get((str(conn_name), enable_iam_auth))
695
+ if cached_entry:
696
+ await cached_entry.force_refresh()
494
697
  raise
495
698
 
496
699
  async def _remove_cached(
@@ -506,6 +709,87 @@ class Connector:
506
709
  cache = self._cache.pop((instance_connection_string, enable_iam_auth))
507
710
  await cache.close()
508
711
 
712
+ async def _resolve_targets(
713
+ self,
714
+ conn_info: ConnectionInfo,
715
+ preferred_ips: list[str],
716
+ ) -> list[str]:
717
+ """Resolves target IP addresses for a Cloud SQL instance, with DNS fallback."""
718
+ targets: list[str] = []
719
+ if conn_info.conn_name.domain_name and isinstance(self._resolver, DnsResolver):
720
+ try:
721
+ ips = await self._resolver.resolve_a_record(
722
+ conn_info.conn_name.domain_name
723
+ )
724
+ if ips:
725
+ targets.extend(ips)
726
+ logger.debug(
727
+ f"['{conn_info.conn_name}']: Custom DNS name "
728
+ f"'{conn_info.conn_name.domain_name}' resolved to '{ips}', "
729
+ "using it to connect"
730
+ )
731
+ else:
732
+ logger.debug(
733
+ f"['{conn_info.conn_name}']: Custom DNS name "
734
+ f"'{conn_info.conn_name.domain_name}' resolved but returned no "
735
+ f"entries, using '{preferred_ips}' from instance metadata"
736
+ )
737
+ targets.extend(preferred_ips)
738
+ except Exception as e: # noqa: BLE001
739
+ logger.debug(
740
+ f"['{conn_info.conn_name}']: Custom DNS name "
741
+ f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
742
+ f"address: {e}, using '{preferred_ips}' from instance metadata"
743
+ )
744
+ targets.extend(preferred_ips)
745
+ else:
746
+ targets.extend(preferred_ips)
747
+ return targets
748
+
749
+ async def _dial_probe_connection(
750
+ self,
751
+ conn_info: ConnectionInfo,
752
+ enable_iam_auth: bool,
753
+ ip_type: IPTypes | None = None,
754
+ ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
755
+ """Dials a TLS probe connection using the connector's IP resolution and SSLContext."""
756
+ effective_ip_type = ip_type if ip_type is not None else self._ip_type
757
+ preferred_ips = conn_info.get_preferred_ips(effective_ip_type)
758
+ targets = await self._resolve_targets(conn_info, preferred_ips)
759
+
760
+ if not targets:
761
+ raise ConnectionError(
762
+ f"No target IP addresses found for {conn_info.conn_name}"
763
+ )
764
+
765
+ ssl_context = await conn_info.create_ssl_context(enable_iam_auth)
766
+ server_hostname = (
767
+ conn_info.conn_name.domain_name
768
+ if conn_info.conn_name.domain_name
769
+ else None
770
+ )
771
+
772
+ last_err: Exception | None = None
773
+ for target in targets:
774
+ try:
775
+ reader, writer = await asyncio.wait_for(
776
+ asyncio.open_connection(
777
+ host=target,
778
+ port=SERVER_PROXY_PORT,
779
+ ssl=ssl_context,
780
+ server_hostname=server_hostname,
781
+ ),
782
+ timeout=float(max(1, self._timeout)),
783
+ )
784
+ return reader, writer
785
+ except Exception as e: # noqa: BLE001
786
+ last_err = e
787
+ if last_err:
788
+ raise last_err
789
+ raise ConnectionError(
790
+ f"Failed to connect to any target for {conn_info.conn_name}"
791
+ )
792
+
509
793
  def __enter__(self) -> Any:
510
794
  """Enter context manager by returning Connector object"""
511
795
  return self
@@ -538,8 +822,11 @@ class Connector:
538
822
  close_future = asyncio.run_coroutine_threadsafe(
539
823
  self.close_async(), loop=self._loop
540
824
  )
541
- # Will attempt to safely shut down tasks for 3s
542
- close_future.result(timeout=3)
825
+ try:
826
+ # Will attempt to safely shut down tasks for 3s
827
+ close_future.result(timeout=3)
828
+ except Exception as e: # noqa: BLE001
829
+ logger.error(f"Error during close_async: {e}")
543
830
  # if background thread exists for Connector, clean it up
544
831
  if self._thread:
545
832
  if self._loop.is_running():
@@ -552,9 +839,14 @@ class Connector:
552
839
  """Helper function to cancel the cache's tasks
553
840
  and close aiohttp.ClientSession."""
554
841
  self._closed = True
842
+ self._sqldata_client = None
555
843
  if self._client:
556
844
  await self._client.close()
557
- await asyncio.gather(*[cache.close() for cache in self._cache.values()])
845
+ await asyncio.gather(
846
+ *[cache.close() for cache in self._cache.values()],
847
+ *[client.close() for client in list(self._sqldata_clients)],
848
+ return_exceptions=True,
849
+ )
558
850
 
559
851
 
560
852
  async def create_async_connector(
@@ -570,6 +862,9 @@ async def create_async_connector(
570
862
  refresh_strategy: str | RefreshStrategy = RefreshStrategy.BACKGROUND,
571
863
  resolver: type[DefaultResolver | DnsResolver] = DefaultResolver,
572
864
  failover_period: int = 30,
865
+ sql_data_endpoint: str | None = None,
866
+ sql_data_stream_timeout: int = 7200,
867
+ resource_exhausted_cooldown_period: float = 5.0,
573
868
  ) -> Connector:
574
869
  """Helper function to create Connector object for asyncio connections.
575
870
 
@@ -579,8 +874,11 @@ async def create_async_connector(
579
874
  Args:
580
875
  ip_type (str | IPTypes): The default IP address type used to connect to
581
876
  Cloud SQL instances. Can be one of the following:
582
- IPTypes.PUBLIC ("PUBLIC"), IPTypes.PRIVATE ("PRIVATE"), or
583
- IPTypes.PSC ("PSC"). Default: IPTypes.PUBLIC
877
+ IPTypes.PUBLIC ("PUBLIC"), IPTypes.PRIVATE ("PRIVATE"),
878
+ IPTypes.PSC ("PSC"), or IPTypes.SQL_DATA ("SQL_DATA").
879
+ Note: IPTypes.SQL_DATA is not currently supported with the
880
+ asyncpg driver.
881
+ Default: IPTypes.PUBLIC
584
882
 
585
883
  enable_iam_auth (bool): Enables automatic IAM database authentication
586
884
  (Postgres and MySQL) as the default authentication method for all
@@ -626,6 +924,16 @@ async def create_async_connector(
626
924
  Must be used with `resolver=DnsResolver` to have any effect.
627
925
  Default: 30
628
926
 
927
+ sql_data_endpoint (str | None): Endpoint host for SQL Data Service
928
+ calls. If not specified, derived from `sqladmin_api_endpoint`.
929
+ Default: "sqladmin.googleapis.com".
930
+
931
+ sql_data_stream_timeout (int): Timeout in seconds for the SQL Data
932
+ Service gRPC stream. Default: 7200.
933
+
934
+ resource_exhausted_cooldown_period (float): Cooldown period in seconds
935
+ after a ResourceExhausted error. Default: 5.0.
936
+
629
937
  Returns:
630
938
  A Connector instance configured with running event loop.
631
939
  """
@@ -645,4 +953,7 @@ async def create_async_connector(
645
953
  refresh_strategy=refresh_strategy,
646
954
  resolver=resolver,
647
955
  failover_period=failover_period,
956
+ sql_data_endpoint=sql_data_endpoint,
957
+ sql_data_stream_timeout=sql_data_stream_timeout,
958
+ resource_exhausted_cooldown_period=resource_exhausted_cooldown_period,
648
959
  )