grpc-client-kit 0.1.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.
@@ -0,0 +1,511 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+ from uuid import uuid4
8
+ from weakref import WeakKeyDictionary
9
+
10
+ import grpc.aio
11
+
12
+ from .protocols import (
13
+ ChannelProviderProtocol,
14
+ GrpcClientMetricsProtocol,
15
+ HealthCheckerProtocol,
16
+ )
17
+ from .utils import create_aio_channel
18
+ from .validation import validate_target
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ DEFAULT_MAX_CHANNELS_PER_TARGET = 1
23
+ DEFAULT_IDLE_TIMEOUT = 300.0 # 5 minutes
24
+
25
+ # Idle management is delegated to gRPC core: past this timeout without active RPCs the channel
26
+ # parks its connection (goes IDLE) and transparently reconnects on the next call. The pool must
27
+ # never close idle channels itself — a channel object handed to a caller stays valid for life,
28
+ # and an in-flight RPC can never be killed by idleness (core only idles when there are none).
29
+ _IDLE_OPTION = "grpc.client_idle_timeout_ms"
30
+
31
+ # Each interceptor instance is tagged once and keeps its tag for life. A pooled channel holds its
32
+ # interceptors alive, so a tag can never be reassigned to a different object the way id() is after
33
+ # garbage collection — and the pool outlives the clients that filled it.
34
+ _INTERCEPTOR_TOKENS: WeakKeyDictionary[grpc.aio.ClientInterceptor, str] = WeakKeyDictionary()
35
+
36
+
37
+ def _interceptor_token(interceptor: grpc.aio.ClientInterceptor) -> str:
38
+ """Return the stable token of a single interceptor, minting it on first use.
39
+
40
+ Args:
41
+ interceptor: The interceptor instance to identify.
42
+
43
+ Returns:
44
+ A token that lives exactly as long as the interceptor instance.
45
+ """
46
+ token = _INTERCEPTOR_TOKENS.get(interceptor)
47
+ if token is None:
48
+ token = uuid4().hex
49
+ _INTERCEPTOR_TOKENS[interceptor] = token
50
+ return token
51
+
52
+
53
+ def chain_token(interceptors: list[grpc.aio.ClientInterceptor] | None) -> str | None:
54
+ """Return a stable identity for an interceptor chain.
55
+
56
+ The token changes only when the chain's members or their order change, so a client that reuses
57
+ its chain keeps hitting the same pooled channel, while a client with a different chain never
58
+ inherits someone else's.
59
+
60
+ Args:
61
+ interceptors: The chain to identify, in call order.
62
+
63
+ Returns:
64
+ A token identifying the chain, or None when the chain is empty (a channel built without
65
+ interceptors is interchangeable with any other such channel).
66
+ """
67
+ if not interceptors:
68
+ return None
69
+ return "|".join(_interceptor_token(interceptor) for interceptor in interceptors)
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class ChannelKey:
74
+ """Full identity of a pooled channel.
75
+
76
+ Two callers may share a channel only when every field matches: gRPC binds credentials, options,
77
+ compression and interceptors to a channel at creation time and none of them can be changed or
78
+ added afterwards, so a channel built for one combination cannot serve another.
79
+
80
+ Attributes:
81
+ target: The target address (host:port).
82
+ insecure: Whether the channel is insecure.
83
+ credentials: TLS credentials, compared by identity because gRPC credentials define no
84
+ equality — reuse one credentials object instead of rebuilding it per call.
85
+ options: gRPC channel options, normalized to a tuple and compared by value.
86
+ compression: The channel compression setting.
87
+ interceptors_token: Stable identity of the interceptor chain, or None when there is none.
88
+ """
89
+
90
+ target: str
91
+ insecure: bool
92
+ credentials: grpc.ChannelCredentials | None = None
93
+ options: tuple[tuple[str, Any], ...] | None = None
94
+ compression: grpc.Compression | None = None
95
+ interceptors_token: str | None = None
96
+
97
+ @classmethod
98
+ def build(
99
+ cls,
100
+ target: str,
101
+ insecure: bool = False,
102
+ credentials: grpc.ChannelCredentials | None = None,
103
+ options: list[tuple[str, Any]] | None = None,
104
+ compression: grpc.Compression | None = None,
105
+ interceptors: list[grpc.aio.ClientInterceptor] | None = None,
106
+ ) -> ChannelKey:
107
+ """Build a key from the arguments of a channel request.
108
+
109
+ Args:
110
+ target: The target address (host:port).
111
+ insecure: Whether to use an insecure channel.
112
+ credentials: Optional TLS credentials for a secure channel.
113
+ options: Optional gRPC channel options.
114
+ compression: Optional gRPC compression setting.
115
+ interceptors: Optional interceptor chain to bind to the channel.
116
+
117
+ Returns:
118
+ The hashable identity of the requested channel.
119
+ """
120
+ return cls(
121
+ target=target,
122
+ insecure=insecure,
123
+ credentials=credentials,
124
+ options=tuple(options) if options is not None else None,
125
+ compression=compression,
126
+ interceptors_token=chain_token(interceptors),
127
+ )
128
+
129
+
130
+ @dataclass(slots=True)
131
+ class ChannelWrapper:
132
+ """Wrapper for a gRPC channel with health status."""
133
+
134
+ channel: grpc.aio.Channel
135
+ target: str
136
+ is_healthy: bool = True
137
+
138
+
139
+ @dataclass(slots=True)
140
+ class _PoolEntry:
141
+ """Everything the pool keeps for one channel identity.
142
+
143
+ Attributes:
144
+ lock: Guards ``channels`` and ``index``.
145
+ channels: Channels created for this identity.
146
+ index: Cursor for round-robin selection among healthy channels.
147
+ users: Number of coroutines that checked the entry out. An entry is only ever dropped at
148
+ zero, so a coroutine can never end up holding a lock the pool has already replaced.
149
+ active: Cleared when the entry leaves the pool, so a late waiter does not resurrect it.
150
+ """
151
+
152
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
153
+ channels: list[ChannelWrapper] = field(default_factory=list)
154
+ index: int = 0
155
+ users: int = 0
156
+ active: bool = True
157
+
158
+
159
+ class ChannelPool(ChannelProviderProtocol):
160
+ """Advanced channel pool for gRPC channels.
161
+
162
+ Channels are pooled by their full identity (see :class:`ChannelKey`), not by target alone:
163
+ asking for ``host:1`` over TLS never returns the insecure channel someone else opened for the
164
+ same address, and a client never inherits another client's interceptor chain.
165
+
166
+ Async-safe through per-identity locking to minimize contention and prevent deadlocks.
167
+ """
168
+
169
+ def __init__(
170
+ self,
171
+ max_channels_per_target: int = DEFAULT_MAX_CHANNELS_PER_TARGET,
172
+ idle_timeout: float = DEFAULT_IDLE_TIMEOUT,
173
+ health_checker: HealthCheckerProtocol | None = None,
174
+ metrics: GrpcClientMetricsProtocol | None = None,
175
+ ) -> None:
176
+ """Initialize the channel pool.
177
+
178
+ Args:
179
+ max_channels_per_target: Maximum number of concurrent channels to keep per identity.
180
+ idle_timeout: Seconds without active RPCs after which a channel parks its connection.
181
+ Enforced by gRPC core (``grpc.client_idle_timeout_ms``), never by the pool closing
182
+ channels: a parked channel reconnects transparently on the next call, so held stubs
183
+ and long streams survive any idle period. ``0`` or less disables idling.
184
+ health_checker: Optional health checker for monitoring target health.
185
+ metrics: Optional metrics registry for pool statistics.
186
+
187
+ Raises:
188
+ ValueError: If max_channels_per_target is not positive.
189
+ """
190
+ if max_channels_per_target <= 0:
191
+ raise ValueError("max_channels_per_target must be positive")
192
+
193
+ self._max_channels_per_target = max_channels_per_target
194
+ self._idle_timeout = idle_timeout
195
+ self._health_checker = health_checker
196
+ self._metrics = metrics
197
+
198
+ self._entries: dict[ChannelKey, _PoolEntry] = {}
199
+ self._pool_lock = asyncio.Lock()
200
+ self._closing = False
201
+
202
+ async def _checkout(self, key: ChannelKey) -> _PoolEntry:
203
+ """Get or create the entry for a key and mark it as in use.
204
+
205
+ Args:
206
+ key: The channel identity.
207
+
208
+ Returns:
209
+ The entry, whose lock the caller must take before touching its channels.
210
+ """
211
+ async with self._pool_lock:
212
+ entry = self._entries.get(key)
213
+ if entry is None:
214
+ entry = _PoolEntry()
215
+ self._entries[key] = entry
216
+ entry.users += 1
217
+ return entry
218
+
219
+ async def _checkin(self, key: ChannelKey, entry: _PoolEntry) -> None:
220
+ """Release an entry and drop it if it became empty and unused.
221
+
222
+ Args:
223
+ key: The channel identity the entry was checked out under.
224
+ entry: The entry to release.
225
+ """
226
+ async with self._pool_lock:
227
+ entry.users -= 1
228
+ # Nobody holds the entry at zero users, so reading `channels` without its lock is safe
229
+ # and no coroutine can be waiting on the lock we are about to discard.
230
+ if entry.users == 0 and not entry.channels and self._entries.get(key) is entry:
231
+ entry.active = False
232
+ del self._entries[key]
233
+
234
+ def _update_pool_metrics(self) -> None:
235
+ """Update pool usage metrics if available."""
236
+ if not self._metrics:
237
+ return
238
+
239
+ total_channels = sum(len(entry.channels) for entry in self._entries.values())
240
+
241
+ try:
242
+ self._metrics.record_pool_stats(
243
+ active_channels=total_channels,
244
+ # One target can back several identities, so report distinct addresses.
245
+ idle_targets=len({key.target for key in self._entries}),
246
+ )
247
+ except Exception:
248
+ logger.exception("Failed to record pool metrics")
249
+
250
+ def make_key(
251
+ self,
252
+ target: str,
253
+ insecure: bool = False,
254
+ credentials: grpc.ChannelCredentials | None = None,
255
+ options: list[tuple[str, Any]] | None = None,
256
+ compression: grpc.Compression | None = None,
257
+ interceptors: list[grpc.aio.ClientInterceptor] | None = None,
258
+ ) -> ChannelKey:
259
+ """Validate a channel request and build its pool identity.
260
+
261
+ The result is safe to cache and hand back through ``get_channel(key=...)``: a client whose
262
+ target, configuration and chain never change should pay for validation and identity
263
+ hashing once, not on every call.
264
+
265
+ Args:
266
+ target: The target address (host:port).
267
+ insecure: Whether to use an insecure channel.
268
+ credentials: Optional TLS credentials for a secure channel.
269
+ options: Optional gRPC channel options.
270
+ compression: Optional gRPC compression setting.
271
+ interceptors: Optional interceptor chain to bind to the channel.
272
+
273
+ Returns:
274
+ The hashable identity the pool files this request under.
275
+ """
276
+ validate_target(target)
277
+ return ChannelKey.build(
278
+ target,
279
+ insecure=insecure,
280
+ credentials=credentials,
281
+ options=self._with_idle_option(options),
282
+ compression=compression,
283
+ interceptors=interceptors,
284
+ )
285
+
286
+ def _with_idle_option(self, options: list[tuple[str, Any]] | None) -> list[tuple[str, Any]] | None:
287
+ """Merge the pool's idle timeout into channel options, letting an explicit value win."""
288
+ if self._idle_timeout <= 0:
289
+ return options
290
+ if options is not None and any(name == _IDLE_OPTION for name, _ in options):
291
+ return options
292
+ merged: list[tuple[str, Any]] = list(options) if options is not None else []
293
+ merged.append((_IDLE_OPTION, int(self._idle_timeout * 1000)))
294
+ return merged
295
+
296
+ async def get_channel(
297
+ self,
298
+ target: str,
299
+ insecure: bool = False,
300
+ credentials: grpc.ChannelCredentials | None = None,
301
+ options: list[tuple[str, Any]] | None = None,
302
+ compression: grpc.Compression | None = None,
303
+ interceptors: list[grpc.aio.ClientInterceptor] | None = None,
304
+ key: ChannelKey | None = None,
305
+ ) -> grpc.aio.Channel:
306
+ """Get or create a gRPC channel for the requested channel identity.
307
+
308
+ Implements round-robin selection among healthy channels of that identity. If all channels
309
+ are unhealthy or the limit is reached, it will either create a new channel or return an
310
+ existing one as a fallback.
311
+
312
+ Args:
313
+ target: The target address (host:port).
314
+ insecure: Whether to use an insecure channel.
315
+ credentials: Optional TLS credentials for a secure channel.
316
+ options: Optional gRPC channel options.
317
+ compression: Optional gRPC compression setting.
318
+ interceptors: Optional list of interceptors to bind to the channel.
319
+ key: Precomputed identity from :meth:`make_key`. When given, the target is not
320
+ re-validated and the identity is not rebuilt; the other arguments must be the ones
321
+ the key was built from.
322
+
323
+ Returns:
324
+ An async gRPC channel.
325
+
326
+ Raises:
327
+ RuntimeError: If the pool is closing.
328
+ """
329
+ if self._closing:
330
+ raise RuntimeError("ChannelPool is closing")
331
+
332
+ if key is None:
333
+ key = self.make_key(
334
+ target,
335
+ insecure=insecure,
336
+ credentials=credentials,
337
+ options=options,
338
+ compression=compression,
339
+ interceptors=interceptors,
340
+ )
341
+
342
+ entry = await self._checkout(key)
343
+ try:
344
+ async with entry.lock:
345
+ # close_all() may have detached this entry while we waited for the lock; handing
346
+ # out a channel the pool no longer tracks would leak it past shutdown.
347
+ if self._closing or not entry.active:
348
+ raise RuntimeError("ChannelPool is closing")
349
+
350
+ wrapper = self._select_channel(entry)
351
+ if wrapper is None:
352
+ wrapper = self._add_channel(entry, key, interceptors)
353
+ finally:
354
+ await self._checkin(key, entry)
355
+
356
+ return wrapper.channel
357
+
358
+ def _select_channel(self, entry: _PoolEntry) -> ChannelWrapper | None:
359
+ """Pick the next healthy channel of an entry in round-robin order.
360
+
361
+ Args:
362
+ entry: The entry to select from; its lock must be held.
363
+
364
+ Returns:
365
+ The selected channel wrapper, or None if the entry has no healthy channel.
366
+ """
367
+ # The flag is written through the public update_channel_health seam, so it is honoured
368
+ # regardless of whether this pool owns a checker — an external checker counts too.
369
+ healthy = [w for w in entry.channels if w.is_healthy]
370
+ if not healthy:
371
+ return None
372
+
373
+ position = entry.index % len(healthy)
374
+ entry.index = (position + 1) % len(healthy)
375
+ return healthy[position]
376
+
377
+ def _add_channel(
378
+ self,
379
+ entry: _PoolEntry,
380
+ key: ChannelKey,
381
+ interceptors: list[grpc.aio.ClientInterceptor] | None,
382
+ ) -> ChannelWrapper:
383
+ """Grow an entry by one channel, or fall back to an existing one at the limit.
384
+
385
+ The channel is built from the key it is filed under, so what the pool promises and what
386
+ gRPC was asked for cannot drift apart.
387
+
388
+ Args:
389
+ entry: The entry to grow; its lock must be held.
390
+ key: The identity of the channel to create.
391
+ interceptors: The chain the key's token stands for.
392
+
393
+ Returns:
394
+ The channel wrapper to serve this request with.
395
+ """
396
+ if len(entry.channels) >= self._max_channels_per_target:
397
+ # Every channel is unhealthy and the limit is reached: serve the oldest one anyway, it
398
+ # may have recovered since the last health check and is better than no channel at all.
399
+ return entry.channels[0]
400
+
401
+ logger.info("Creating new gRPC channel for target: %s", key.target)
402
+ channel = self._create_aio_channel(
403
+ key.target,
404
+ insecure=key.insecure,
405
+ credentials=key.credentials,
406
+ options=list(key.options) if key.options is not None else None,
407
+ compression=key.compression,
408
+ interceptors=interceptors,
409
+ )
410
+ wrapper = ChannelWrapper(channel=channel, target=key.target)
411
+ entry.channels.append(wrapper)
412
+ self._update_pool_metrics()
413
+ return wrapper
414
+
415
+ def _create_aio_channel(
416
+ self,
417
+ target: str,
418
+ insecure: bool = False,
419
+ credentials: grpc.ChannelCredentials | None = None,
420
+ options: list[tuple[str, Any]] | None = None,
421
+ compression: grpc.Compression | None = None,
422
+ interceptors: list[grpc.aio.ClientInterceptor] | None = None,
423
+ ) -> grpc.aio.Channel:
424
+ """Internal helper to create a new async gRPC channel."""
425
+ return create_aio_channel(
426
+ target,
427
+ insecure=insecure,
428
+ credentials=credentials,
429
+ options=options,
430
+ compression=compression,
431
+ interceptors=interceptors,
432
+ )
433
+
434
+ async def close_all(self, grace: float | None = None) -> None:
435
+ """Close all pooled channels.
436
+
437
+ The pool stays usable afterwards: this drains it rather than retiring it, so a shared pool
438
+ can outlive one shutdown and an ``async with`` block can be entered again.
439
+
440
+ Args:
441
+ grace: Optional time to wait for active RPCs to finish.
442
+ """
443
+ self._closing = True
444
+ try:
445
+ async with self._pool_lock:
446
+ entries = list(self._entries.values())
447
+ self._entries.clear()
448
+
449
+ wrappers: list[ChannelWrapper] = []
450
+ for entry in entries:
451
+ entry.active = False
452
+ wrappers.extend(entry.channels)
453
+ entry.channels.clear()
454
+
455
+ if wrappers:
456
+ logger.info("Closing %d pooled gRPC channels", len(wrappers))
457
+ await asyncio.gather(*(w.channel.close(grace=grace) for w in wrappers), return_exceptions=True)
458
+
459
+ self._update_pool_metrics()
460
+ finally:
461
+ self._closing = False
462
+
463
+ async def health_check(self, target: str | None = None) -> bool:
464
+ """Check if pool is healthy (ready to accept requests).
465
+
466
+ Args:
467
+ target: Optional target to check for reachability instead of the pool itself.
468
+
469
+ Returns:
470
+ True if the pool (or the given target) can serve requests.
471
+ """
472
+ if self._closing:
473
+ return False
474
+
475
+ if target and self._health_checker:
476
+ return await self._health_checker.check_health(target)
477
+
478
+ return True
479
+
480
+ async def update_channel_health(self, target: str, is_healthy: bool) -> None:
481
+ """Update health status for every pooled channel of a target.
482
+
483
+ All channel identities sharing the address are updated, since health is a property of the
484
+ server rather than of the channel configuration.
485
+
486
+ Args:
487
+ target: The target address (host:port).
488
+ is_healthy: Whether the target is healthy.
489
+ """
490
+ async with self._pool_lock:
491
+ checked_out = [(key, entry) for key, entry in self._entries.items() if key.target == target]
492
+ for _, entry in checked_out:
493
+ entry.users += 1
494
+
495
+ try:
496
+ for _, entry in checked_out:
497
+ async with entry.lock:
498
+ for wrapper in entry.channels:
499
+ wrapper.is_healthy = is_healthy
500
+ finally:
501
+ for key, entry in checked_out:
502
+ await self._checkin(key, entry)
503
+
504
+ async def __aenter__(self) -> ChannelPool:
505
+ return self
506
+
507
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
508
+ await self.close_all()
509
+
510
+
511
+ __all__ = ["ChannelKey", "ChannelPool", "ChannelWrapper", "chain_token"]