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.
@@ -41,6 +41,7 @@ class IPTypes(Enum):
41
41
  PUBLIC = "PRIMARY"
42
42
  PRIVATE = "PRIVATE"
43
43
  PSC = "PSC"
44
+ SQL_DATA = "SQL_DATA"
44
45
 
45
46
  @classmethod
46
47
  def _missing_(cls, value: object) -> None:
@@ -54,6 +55,8 @@ class IPTypes(Enum):
54
55
  """Convert IP type from a str into IPTypes."""
55
56
  if ip_type_str.upper() == "PUBLIC":
56
57
  ip_type_str = "PRIMARY"
58
+ elif ip_type_str.upper() in ("SQLDATA", "SQL_DATA"):
59
+ ip_type_str = "SQL_DATA"
57
60
  return cls(ip_type_str.upper())
58
61
 
59
62
 
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  """
16
16
 
17
+ from __future__ import annotations
18
+
17
19
 
18
20
  class ConnectorLoopError(Exception):
19
21
  """
@@ -91,3 +93,34 @@ class ClosedConnectorError(Exception):
91
93
  Exception to be raised when a Connector is closed and connect method is
92
94
  called on it.
93
95
  """
96
+
97
+
98
+ class CloudSQLConnectionError(Exception):
99
+ """
100
+ Exception to be raised when a connection cannot be established to a Cloud SQL instance.
101
+ """
102
+
103
+
104
+ class ResourceExhaustedError(CloudSQLConnectionError):
105
+ """
106
+ Exception to be raised when a connection cannot be established because
107
+ the SQL Data Service is busy / in cooldown due to resource exhaustion.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ message: str,
113
+ connection_name: str | None = None,
114
+ raw_error: Exception | None = None,
115
+ ) -> None:
116
+ self.message = message
117
+ self.connection_name = connection_name
118
+ self.raw_error = raw_error
119
+ super().__init__(
120
+ f"[{connection_name}] {message}: {raw_error}"
121
+ if connection_name and raw_error
122
+ else f"[{connection_name}] {message}"
123
+ if connection_name
124
+ else message
125
+ )
126
+
@@ -17,16 +17,21 @@ limitations under the License.
17
17
  from __future__ import annotations
18
18
 
19
19
  import asyncio
20
+ from collections.abc import Awaitable
21
+ from collections.abc import Callable
20
22
  from datetime import datetime
21
23
  from datetime import timedelta
22
- from datetime import timezone
24
+ from datetime import UTC
23
25
  import logging
24
26
 
25
27
  from google.cloud.sql.connector.client import CloudSQLClient
26
28
  from google.cloud.sql.connector.connection_info import ConnectionInfo
27
29
  from google.cloud.sql.connector.connection_info import ConnectionInfoCache
28
30
  from google.cloud.sql.connector.connection_name import ConnectionName
31
+ from google.cloud.sql.connector.enums import IPTypes
29
32
  from google.cloud.sql.connector.exceptions import RefreshNotValidError
33
+ from google.cloud.sql.connector.postgres_connection import build_postgres_startup_packet
34
+ from google.cloud.sql.connector.postgres_connection import build_terminate_packet
30
35
  from google.cloud.sql.connector.rate_limiter import AsyncRateLimiter
31
36
  from google.cloud.sql.connector.refresh_utils import _is_valid
32
37
  from google.cloud.sql.connector.refresh_utils import _seconds_until_refresh
@@ -34,6 +39,8 @@ from google.cloud.sql.connector.refresh_utils import _seconds_until_refresh
34
39
  logger = logging.getLogger(name=__name__)
35
40
 
36
41
  APPLICATION_NAME = "cloud-sql-python-connector"
42
+ SERVER_PROXY_PORT = 3307
43
+ DEFAULT_CONNECT_TIMEOUT = 30
37
44
 
38
45
 
39
46
  class RefreshAheadCache(ConnectionInfoCache):
@@ -50,6 +57,15 @@ class RefreshAheadCache(ConnectionInfoCache):
50
57
  client: CloudSQLClient,
51
58
  keys: asyncio.Future,
52
59
  enable_iam_auth: bool = False,
60
+ ip_type: IPTypes | str = IPTypes.PUBLIC,
61
+ timeout: int = DEFAULT_CONNECT_TIMEOUT,
62
+ dial_func: (
63
+ Callable[
64
+ [ConnectionInfo],
65
+ Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]],
66
+ ]
67
+ | None
68
+ ) = None,
53
69
  ) -> None:
54
70
  """Initializes a RefreshAheadCache instance.
55
71
 
@@ -62,10 +78,18 @@ class RefreshAheadCache(ConnectionInfoCache):
62
78
  enable_iam_auth (bool): Enables automatic IAM database authentication
63
79
  (Postgres and MySQL) as the default authentication method for all
64
80
  connections.
81
+ ip_type (IPTypes | str): Preferred IP type used to connect to the instance.
82
+ timeout (int): Connect timeout in seconds.
83
+ dial_func: Optional callable to dial probe connections.
65
84
  """
66
85
  self._conn_name = conn_name
67
86
 
68
87
  self._enable_iam_auth = enable_iam_auth
88
+ if isinstance(ip_type, str):
89
+ ip_type = IPTypes._from_str(ip_type)
90
+ self._ip_type = ip_type
91
+ self._timeout = timeout
92
+ self._dial_func = dial_func
69
93
  self._keys = keys
70
94
  self._client = client
71
95
  self._refresh_rate_limiter = AsyncRateLimiter(
@@ -73,10 +97,15 @@ class RefreshAheadCache(ConnectionInfoCache):
73
97
  rate=1 / 30,
74
98
  )
75
99
  self._refresh_in_progress = asyncio.locks.Event()
100
+ self._iam_principals: list[tuple[str, str]] = []
76
101
  self._current: asyncio.Task = self._schedule_refresh(0)
77
102
  self._next: asyncio.Task = self._current
78
103
  self._closed = False
79
104
 
105
+ def record_principal(self, user: str, database: str) -> None:
106
+ """Records a PostgreSQL (user, database) pair observed on an IAM connection."""
107
+ _append_iam_principal(self._iam_principals, user, database)
108
+
80
109
  @property
81
110
  def conn_name(self) -> ConnectionName:
82
111
  return self._conn_name
@@ -119,6 +148,8 @@ class RefreshAheadCache(ConnectionInfoCache):
119
148
  self._keys,
120
149
  self._enable_iam_auth,
121
150
  )
151
+ if self._enable_iam_auth:
152
+ await self._probe_connection(connection_info)
122
153
  logger.debug(
123
154
  f"['{self._conn_name}']: Connection info refresh operation complete"
124
155
  )
@@ -138,6 +169,18 @@ class RefreshAheadCache(ConnectionInfoCache):
138
169
  self._refresh_in_progress.clear()
139
170
  return connection_info
140
171
 
172
+ async def _probe_connection(self, conn_info: ConnectionInfo) -> None:
173
+ """Proactively probes the database to refresh IAM tokens on server-side MCP."""
174
+ await _probe_instance_connection(
175
+ self._conn_name,
176
+ conn_info,
177
+ self._enable_iam_auth,
178
+ self._ip_type,
179
+ self._timeout,
180
+ self._iam_principals,
181
+ self._dial_func,
182
+ )
183
+
141
184
  def _schedule_refresh(self, delay: int) -> asyncio.Task:
142
185
  """
143
186
  Schedule task to sleep and then perform refresh to get ConnectionInfo.
@@ -192,7 +235,7 @@ class RefreshAheadCache(ConnectionInfoCache):
192
235
  logger.debug(
193
236
  f"['{self._conn_name}']: Connection info refresh"
194
237
  " operation scheduled for "
195
- f"{(datetime.now(timezone.utc) + timedelta(seconds=delay)).isoformat(timespec='seconds')} "
238
+ f"{(datetime.now(UTC) + timedelta(seconds=delay)).isoformat(timespec='seconds')} "
196
239
  f"(now + {timedelta(seconds=delay)})"
197
240
  )
198
241
  self._next = self._schedule_refresh(delay)
@@ -223,3 +266,196 @@ class RefreshAheadCache(ConnectionInfoCache):
223
266
  tasks = asyncio.gather(self._current, self._next, return_exceptions=True)
224
267
  await asyncio.wait_for(tasks, timeout=2.0)
225
268
  self._closed = True
269
+
270
+
271
+ def _append_iam_principal(
272
+ principals: list[tuple[str, str]], user: str, database: str
273
+ ) -> None:
274
+ """Appends (user, database) to principals if not already present."""
275
+ if not user:
276
+ return
277
+ if not database:
278
+ database = user
279
+ pair = (user, database)
280
+ if pair not in principals:
281
+ principals.append(pair)
282
+
283
+
284
+ async def _probe_instance_connection(
285
+ conn_name: ConnectionName,
286
+ conn_info: ConnectionInfo,
287
+ enable_iam_auth: bool,
288
+ ip_type: IPTypes,
289
+ timeout: int,
290
+ iam_principals: list[tuple[str, str]],
291
+ dial_func: (
292
+ Callable[
293
+ [ConnectionInfo],
294
+ Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]],
295
+ ]
296
+ | None
297
+ ) = None,
298
+ ) -> None:
299
+ """Proactively probes the database to refresh IAM tokens on server-side MCP."""
300
+ if not enable_iam_auth:
301
+ return
302
+
303
+ # Proactive IAM refresh probe is PostgreSQL-only.
304
+ if not conn_info.database_version.startswith("POSTGRES"):
305
+ logger.debug(
306
+ f"['{conn_name}']: Proactive IAM token refresh probe skipped: instance engine '{conn_info.database_version}' is not PostgreSQL"
307
+ )
308
+ return
309
+
310
+ # Do not probe if no IAM principals have connected yet.
311
+ if not iam_principals:
312
+ logger.debug(
313
+ f"['{conn_name}']: Proactive IAM token refresh probe skipped: no IAM principals connected"
314
+ )
315
+ return
316
+
317
+ targets: list[str] = []
318
+ ssl_context = None
319
+ if dial_func is None:
320
+ if conn_name.domain_name:
321
+ targets.append(conn_name.domain_name)
322
+ if ip_type.value in conn_info.ip_addrs:
323
+ targets.extend(conn_info.ip_addrs[ip_type.value])
324
+
325
+ if not targets:
326
+ logger.debug(
327
+ f"['{conn_name}']: Proactive IAM token refresh probe skipped: no target IP addresses"
328
+ )
329
+ return
330
+
331
+ try:
332
+ ssl_context = await conn_info.create_ssl_context(enable_iam_auth)
333
+ except Exception as e: # noqa: BLE001
334
+ logger.debug(
335
+ f"['{conn_name}']: Failed to create SSL context for probe: {e!s}"
336
+ )
337
+ return
338
+
339
+ loop = asyncio.get_running_loop()
340
+ deadline = loop.time() + float(max(1, timeout))
341
+
342
+ all_succeeded = True
343
+ for user, database in list(iam_principals):
344
+ remaining = deadline - loop.time()
345
+ if remaining <= 0:
346
+ all_succeeded = False
347
+ break
348
+
349
+ probed_principal = False
350
+ if dial_func is not None:
351
+ try:
352
+ reader, writer = await asyncio.wait_for(
353
+ dial_func(conn_info),
354
+ timeout=remaining,
355
+ )
356
+ probed_principal = await _send_startup_probe(
357
+ reader,
358
+ writer,
359
+ user,
360
+ database,
361
+ max(0.0, deadline - loop.time()),
362
+ conn_name,
363
+ )
364
+ except Exception as e: # noqa: BLE001
365
+ logger.debug(
366
+ f"['{conn_name}']: Probing IAM token refresh for ({user}, {database}) failed: {e!s}"
367
+ )
368
+ else:
369
+ for target in targets:
370
+ remaining = deadline - loop.time()
371
+ if remaining <= 0:
372
+ break
373
+ try:
374
+ logger.debug(
375
+ f"['{conn_name}']: Probing IAM token refresh on {target}:{SERVER_PROXY_PORT}"
376
+ )
377
+ reader, writer = await asyncio.wait_for(
378
+ asyncio.open_connection(
379
+ host=target,
380
+ port=SERVER_PROXY_PORT,
381
+ ssl=ssl_context,
382
+ server_hostname=(
383
+ conn_name.domain_name
384
+ if conn_name.domain_name
385
+ else None
386
+ ),
387
+ ),
388
+ timeout=remaining,
389
+ )
390
+ probed_principal = await _send_startup_probe(
391
+ reader,
392
+ writer,
393
+ user,
394
+ database,
395
+ max(0.0, deadline - loop.time()),
396
+ conn_name,
397
+ )
398
+ break
399
+ except Exception as e: # noqa: BLE001
400
+ logger.debug(
401
+ f"['{conn_name}']: Probing IAM token refresh on {target}:{SERVER_PROXY_PORT} failed: {e!s}"
402
+ )
403
+ if not probed_principal:
404
+ all_succeeded = False
405
+
406
+ if all_succeeded:
407
+ logger.debug(
408
+ f"['{conn_name}']: Proactive IAM token refresh probe successful"
409
+ )
410
+ else:
411
+ logger.debug(
412
+ f"['{conn_name}']: Proactive IAM token refresh probe encountered error across all targets"
413
+ )
414
+
415
+
416
+ async def _send_startup_probe(
417
+ reader: asyncio.StreamReader,
418
+ writer: asyncio.StreamWriter,
419
+ user: str,
420
+ database: str,
421
+ timeout: float,
422
+ conn_name: ConnectionName,
423
+ ) -> bool:
424
+ """Sends a PostgreSQL StartupMessage probe and gracefully terminates."""
425
+ loop = asyncio.get_running_loop()
426
+ deadline = loop.time() + max(0.0, float(timeout))
427
+ try:
428
+ remaining = deadline - loop.time()
429
+ if remaining <= 0:
430
+ return False
431
+ writer.write(build_postgres_startup_packet(user, database))
432
+ await asyncio.wait_for(writer.drain(), timeout=remaining)
433
+ await asyncio.wait_for(
434
+ reader.read(1024),
435
+ timeout=max(0.0, deadline - loop.time()),
436
+ )
437
+ writer.write(build_terminate_packet())
438
+ await asyncio.wait_for(
439
+ writer.drain(),
440
+ timeout=max(0.0, deadline - loop.time()),
441
+ )
442
+ eof_timeout = min(max(0.0, deadline - loop.time()), 1.0)
443
+ if eof_timeout > 0:
444
+ try:
445
+ await asyncio.wait_for(reader.read(1024), timeout=eof_timeout)
446
+ except Exception as e: # noqa: BLE001
447
+ logger.debug(
448
+ f"['{conn_name}']: Ignoring error while waiting for server EOF: {e!s}"
449
+ )
450
+ return True
451
+ finally:
452
+ writer.close()
453
+ try:
454
+ await asyncio.wait_for(writer.wait_closed(), timeout=2.0)
455
+ except Exception as e: # noqa: BLE001
456
+ logger.debug(
457
+ f"['{conn_name}']: Ignoring error while waiting for socket to close: {e!s}"
458
+ )
459
+ transport = getattr(writer, "transport", None)
460
+ if transport is not None and hasattr(transport, "abort"):
461
+ transport.abort()
@@ -15,15 +15,21 @@
15
15
  from __future__ import annotations
16
16
 
17
17
  import asyncio
18
+ from collections.abc import Awaitable
19
+ from collections.abc import Callable
18
20
  from datetime import datetime
19
21
  from datetime import timedelta
20
- from datetime import timezone
22
+ from datetime import UTC
21
23
  import logging
22
24
 
23
25
  from google.cloud.sql.connector.client import CloudSQLClient
24
26
  from google.cloud.sql.connector.connection_info import ConnectionInfo
25
27
  from google.cloud.sql.connector.connection_info import ConnectionInfoCache
26
28
  from google.cloud.sql.connector.connection_name import ConnectionName
29
+ from google.cloud.sql.connector.enums import IPTypes
30
+ from google.cloud.sql.connector.instance import _append_iam_principal
31
+ from google.cloud.sql.connector.instance import _probe_instance_connection
32
+ from google.cloud.sql.connector.instance import DEFAULT_CONNECT_TIMEOUT
27
33
  from google.cloud.sql.connector.refresh_utils import _refresh_buffer
28
34
 
29
35
  logger = logging.getLogger(name=__name__)
@@ -44,6 +50,15 @@ class LazyRefreshCache(ConnectionInfoCache):
44
50
  client: CloudSQLClient,
45
51
  keys: asyncio.Future,
46
52
  enable_iam_auth: bool = False,
53
+ ip_type: IPTypes | str = IPTypes.PUBLIC,
54
+ timeout: int = DEFAULT_CONNECT_TIMEOUT,
55
+ dial_func: (
56
+ Callable[
57
+ [ConnectionInfo],
58
+ Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]],
59
+ ]
60
+ | None
61
+ ) = None,
47
62
  ) -> None:
48
63
  """Initializes a LazyRefreshCache instance.
49
64
 
@@ -56,16 +71,29 @@ class LazyRefreshCache(ConnectionInfoCache):
56
71
  enable_iam_auth (bool): Enables automatic IAM database authentication
57
72
  (Postgres and MySQL) as the default authentication method for all
58
73
  connections.
74
+ ip_type (IPTypes | str): Preferred IP type used to connect to the instance.
75
+ timeout (int): Connect timeout in seconds.
76
+ dial_func: Optional callable to dial probe connections.
59
77
  """
60
78
  self._conn_name = conn_name
61
79
  self._enable_iam_auth = enable_iam_auth
80
+ if isinstance(ip_type, str):
81
+ ip_type = IPTypes._from_str(ip_type)
82
+ self._ip_type = ip_type
83
+ self._timeout = timeout
84
+ self._dial_func = dial_func
62
85
  self._keys = keys
63
86
  self._client = client
64
87
  self._lock = asyncio.Lock()
88
+ self._iam_principals: list[tuple[str, str]] = []
65
89
  self._cached: ConnectionInfo | None = None
66
90
  self._needs_refresh = False
67
91
  self._closed = False
68
92
 
93
+ def record_principal(self, user: str, database: str) -> None:
94
+ """Records a PostgreSQL (user, database) pair observed on an IAM connection."""
95
+ _append_iam_principal(self._iam_principals, user, database)
96
+
69
97
  @property
70
98
  def conn_name(self) -> ConnectionName:
71
99
  return self._conn_name
@@ -82,6 +110,18 @@ class LazyRefreshCache(ConnectionInfoCache):
82
110
  async with self._lock:
83
111
  self._needs_refresh = True
84
112
 
113
+ async def _probe_connection(self, conn_info: ConnectionInfo) -> None:
114
+ """Proactively probes the database to refresh IAM tokens on server-side MCP."""
115
+ await _probe_instance_connection(
116
+ self._conn_name,
117
+ conn_info,
118
+ self._enable_iam_auth,
119
+ self._ip_type,
120
+ self._timeout,
121
+ self._iam_principals,
122
+ self._dial_func,
123
+ )
124
+
85
125
  async def connect_info(self) -> ConnectionInfo:
86
126
  """Retrieves ConnectionInfo instance for establishing a secure
87
127
  connection to the Cloud SQL instance.
@@ -93,7 +133,7 @@ class LazyRefreshCache(ConnectionInfoCache):
93
133
  if (
94
134
  self._cached
95
135
  and not self._needs_refresh
96
- and datetime.now(timezone.utc)
136
+ and datetime.now(UTC)
97
137
  < (self._cached.expiration - timedelta(seconds=_refresh_buffer))
98
138
  ):
99
139
  logger.debug(
@@ -126,7 +166,11 @@ class LazyRefreshCache(ConnectionInfoCache):
126
166
  )
127
167
  self._cached = conn_info
128
168
  self._needs_refresh = False
129
- return conn_info
169
+
170
+ if self._enable_iam_auth:
171
+ await self._probe_connection(conn_info)
172
+
173
+ return conn_info
130
174
 
131
175
  async def close(self) -> None:
132
176
  """Close is a no-op and provided purely for a consistent interface with
@@ -15,9 +15,11 @@
15
15
  from __future__ import annotations
16
16
 
17
17
  import asyncio
18
+ from collections.abc import Callable
18
19
  import logging
20
+ import socket
19
21
  import ssl
20
- from typing import Any, Callable
22
+ from typing import Any
21
23
 
22
24
  import aiohttp
23
25
 
@@ -27,6 +29,7 @@ from google.cloud.sql.connector.exceptions import CacheClosedError
27
29
  from google.cloud.sql.connector.exceptions import DnsLoopError
28
30
  from google.cloud.sql.connector.instance import RefreshAheadCache
29
31
  from google.cloud.sql.connector.lazy import LazyRefreshCache
32
+ from google.cloud.sql.connector.postgres_connection import PostgresConnection
30
33
  from google.cloud.sql.connector.resolver import DefaultResolver
31
34
  from google.cloud.sql.connector.resolver import DnsResolver
32
35
  from google.cloud.sql.connector.resolver import is_instance_dns_name
@@ -44,7 +47,7 @@ class MonitoredCache(ConnectionInfoCache):
44
47
  self.resolver = resolver
45
48
  self.cache = cache
46
49
  self.domain_name_ticker: asyncio.Task | None = None
47
- self.sockets: list[ssl.SSLSocket] = []
50
+ self.sockets: list[socket.socket | ssl.SSLSocket | PostgresConnection] = []
48
51
 
49
52
  # If domain name is configured for instance and failover period is set,
50
53
  # poll for DNS record changes.
@@ -77,11 +80,11 @@ class MonitoredCache(ConnectionInfoCache):
77
80
  list of sockets.
78
81
  """
79
82
  open_sockets = []
80
- for socket in self.sockets:
83
+ for sock in self.sockets:
81
84
  # Check fileno for if socket is closed. Will return
82
85
  # -1 on failure, which will be used to signal socket closed.
83
- if socket.fileno() != -1:
84
- open_sockets.append(socket)
86
+ if sock.fileno() != -1:
87
+ open_sockets.append(sock)
85
88
  self.sockets = open_sockets
86
89
 
87
90
  async def _check_domain_name(self) -> None:
@@ -139,6 +142,9 @@ class MonitoredCache(ConnectionInfoCache):
139
142
  return
140
143
  return await self.cache.force_refresh()
141
144
 
145
+ def record_principal(self, user: str, database: str) -> None:
146
+ self.cache.record_principal(user, database)
147
+
142
148
  async def close(self) -> None:
143
149
  # Cancel domain name ticker task.
144
150
  if self.domain_name_ticker:
@@ -159,11 +165,11 @@ class MonitoredCache(ConnectionInfoCache):
159
165
  await self.cache.close()
160
166
 
161
167
  # Close any still open sockets
162
- for socket in self.sockets:
168
+ for sock in self.sockets:
163
169
  # Check fileno for if socket is closed. Will return
164
170
  # -1 on failure, which will be used to signal socket closed.
165
- if socket.fileno() != -1:
166
- socket.close()
171
+ if sock.fileno() != -1:
172
+ sock.close()
167
173
 
168
174
 
169
175
  async def ticker(interval: int, function: Callable, *args: Any, **kwargs: Any) -> None: