grpc-client-kit 0.2.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (36) hide show
  1. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/CHANGELOG.md +14 -0
  2. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/PKG-INFO +12 -4
  3. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/README.md +8 -3
  4. grpc_client_kit-0.4.0/grpc_client_kit/__version__.py +1 -0
  5. grpc_client_kit-0.4.0/grpc_client_kit/dishka.py +277 -0
  6. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/factory.py +20 -9
  7. grpc_client_kit-0.4.0/grpc_client_kit/metrics.py +229 -0
  8. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/pyproject.toml +4 -1
  9. grpc_client_kit-0.2.0/grpc_client_kit/__version__.py +0 -1
  10. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/.gitignore +0 -0
  11. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/LICENSE +0 -0
  12. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/__init__.py +0 -0
  13. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/balancers.py +0 -0
  14. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/channel.py +0 -0
  15. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/client.py +0 -0
  16. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/config.py +0 -0
  17. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/deadline.py +0 -0
  18. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/errors.py +0 -0
  19. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/health.py +0 -0
  20. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/__init__.py +0 -0
  21. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/base.py +0 -0
  22. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/circuit_breaker.py +0 -0
  23. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/client_logging.py +0 -0
  24. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/context.py +0 -0
  25. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/deadline.py +0 -0
  26. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/metrics.py +0 -0
  27. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/outlier.py +0 -0
  28. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/retry.py +0 -0
  29. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/timeout.py +0 -0
  30. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/tracing.py +0 -0
  31. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/interceptors/wait_for_ready.py +0 -0
  32. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/protocols.py +0 -0
  33. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/py.typed +0 -0
  34. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/settings.py +0 -0
  35. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/utils.py +0 -0
  36. {grpc_client_kit-0.2.0 → grpc_client_kit-0.4.0}/grpc_client_kit/validation.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/bedrock-python/grpc-client-kit/compare/grpc-client-kit-v0.3.0...grpc-client-kit-v0.4.0) (2026-09-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * a container-owned channel pool for the Dishka providers, shared by every upstream told to borrow it ([ecdc4e2](https://github.com/bedrock-python/grpc-client-kit/commit/ecdc4e264bdad2b80311f25bfd922ad15ea2db07)), closes [#31](https://github.com/bedrock-python/grpc-client-kit/issues/31)
9
+
10
+ ## [0.3.0](https://github.com/bedrock-python/grpc-client-kit/compare/grpc-client-kit-v0.2.0...grpc-client-kit-v0.3.0) (2026-09-14)
11
+
12
+
13
+ ### Features
14
+
15
+ * Prometheus collector for the metrics protocol and Dishka providers for the client lifecycle ([6dfdb44](https://github.com/bedrock-python/grpc-client-kit/commit/6dfdb44f2f818c6036f465844c96c2151ee511db)), closes [#28](https://github.com/bedrock-python/grpc-client-kit/issues/28)
16
+
3
17
  ## [0.2.0](https://github.com/bedrock-python/grpc-client-kit/compare/grpc-client-kit-v0.1.2...grpc-client-kit-v0.2.0) (2026-09-14)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: grpc-client-kit
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Batteries-optional async gRPC client toolkit (channel pool, load balancing, health checking, streaming-aware interceptors, observability)
5
5
  Project-URL: Repository, https://github.com/bedrock-python/grpc-client-kit
6
6
  Project-URL: Documentation, https://bedrock-python.github.io/grpc-client-kit/
@@ -223,12 +223,15 @@ Requires-Python: >=3.12
223
223
  Requires-Dist: grpcio<2.0.0,>=1.78.0
224
224
  Provides-Extra: all
225
225
  Requires-Dist: deadline-budget<1.0.0,>=0.1.0; extra == 'all'
226
+ Requires-Dist: dishka<2.0.0,>=1.4.0; extra == 'all'
226
227
  Requires-Dist: grpcio-health-checking<2.0.0,>=1.78.0; extra == 'all'
227
228
  Requires-Dist: opentelemetry-api<2.0.0,>=1.30.0; extra == 'all'
228
229
  Requires-Dist: prometheus-client<1.0.0,>=0.20.0; extra == 'all'
229
230
  Requires-Dist: pydantic<3.0,>=2.0.0; extra == 'all'
230
231
  Provides-Extra: deadline
231
232
  Requires-Dist: deadline-budget<1.0.0,>=0.1.0; extra == 'deadline'
233
+ Provides-Extra: dishka
234
+ Requires-Dist: dishka<2.0.0,>=1.4.0; extra == 'dishka'
232
235
  Provides-Extra: health
233
236
  Requires-Dist: grpcio-health-checking<2.0.0,>=1.78.0; extra == 'health'
234
237
  Provides-Extra: metrics
@@ -311,6 +314,7 @@ pip install "grpc-client-kit[metrics,tracing]" # + Prometheus + OpenTelemetry
311
314
  pip install "grpc-client-kit[observability]" # + metrics and tracing together
312
315
  pip install "grpc-client-kit[deadline]" # + request budget propagation
313
316
  pip install "grpc-client-kit[settings]" # + pydantic settings models
317
+ pip install "grpc-client-kit[dishka]" # + Dishka providers
314
318
  pip install "grpc-client-kit[all]" # everything
315
319
  ```
316
320
 
@@ -525,7 +529,9 @@ names the extra to install.
525
529
  | `deadline` + `interceptors.deadline` | The request budget in a contextvar, and the layer that trims every call to it | `deadline` |
526
530
  | `interceptors.tracing` | OpenTelemetry CLIENT spans and `traceparent` injection | `tracing` |
527
531
  | `interceptors.metrics` | Counters, latency histograms and an in-flight gauge | core¹ |
532
+ | `metrics.GrpcClientMetrics` | The Prometheus collector for them, shaped to join `grpc-server-kit`'s series | `metrics` |
528
533
  | `health.HealthChecker` | Background `grpc.health.v1` probing with per-target backoff | `health` |
534
+ | `dishka.*` | Dishka providers owning the factory's lifecycle, one component per upstream, one pool shared by several | `dishka` |
529
535
  | `protocols` / `validation` / `utils` | Settings seams, target validation, channel and metadata helpers | core |
530
536
 
531
537
  ¹ The metrics interceptor records through `GrpcClientMetricsProtocol` and works
@@ -537,11 +543,12 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
537
543
  | :--- | :--- | :--- |
538
544
  | `health` | `grpcio-health-checking` | `HealthChecker`, health-aware balancing and pooling |
539
545
  | `tracing` | `opentelemetry-api` | `AsyncClientTracingInterceptor` (a pass-through without it) |
540
- | `metrics` | `prometheus-client` | the default metrics backend; a custom registry needs no extra |
546
+ | `metrics` | `prometheus-client` | `GrpcClientMetrics`, the Prometheus collector; a custom registry needs no extra |
541
547
  | `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
542
548
  | `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
549
+ | `dishka` | `dishka` | `grpc_client_providers`, the three providers behind it, and the pool provider several upstreams share |
543
550
  | `observability` | `metrics` + `tracing` | both of the above |
544
- | `all` | `deadline` + `health` + `metrics` + `settings` + `tracing` | everything |
551
+ | `all` | `deadline` + `dishka` + `health` + `metrics` + `settings` + `tracing` | everything |
545
552
 
546
553
  `deadline` is deliberately not part of `observability`: propagating a budget is
547
554
  resilience, not telemetry, and an observability extra should not pull in a
@@ -575,7 +582,8 @@ Full documentation at [bedrock-python.github.io/grpc-client-kit](https://bedrock
575
582
  | [Load balancing](https://bedrock-python.github.io/grpc-client-kit/guide/load-balancing/) | round-robin, random and weighted selection with health filtering |
576
583
  | [Health](https://bedrock-python.github.io/grpc-client-kit/guide/health/) | `grpc.health.v1` probing, per-target backoff and cold starts |
577
584
  | [Observability](https://bedrock-python.github.io/grpc-client-kit/guide/observability/) | log records, CLIENT spans and the metrics an RPC emits |
578
- | [Advanced](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/) | interceptors that re-issue calls, target validation, ownership and DI wiring |
585
+ | [Dependency injection](https://bedrock-python.github.io/grpc-client-kit/guide/dependency-injection/) | the Dishka providers, what they own, one component per upstream, one pool shared by several |
586
+ | [Advanced](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/) | interceptors that re-issue calls, target validation and ownership |
579
587
  | [API reference](https://bedrock-python.github.io/grpc-client-kit/reference/) | generated from the source |
580
588
  | [For AI agents](https://bedrock-python.github.io/grpc-client-kit/agents/) | the whole API surface, the rules that break code when broken and a map of the rest, on one page to hand to a coding assistant |
581
589
 
@@ -67,6 +67,7 @@ pip install "grpc-client-kit[metrics,tracing]" # + Prometheus + OpenTelemetry
67
67
  pip install "grpc-client-kit[observability]" # + metrics and tracing together
68
68
  pip install "grpc-client-kit[deadline]" # + request budget propagation
69
69
  pip install "grpc-client-kit[settings]" # + pydantic settings models
70
+ pip install "grpc-client-kit[dishka]" # + Dishka providers
70
71
  pip install "grpc-client-kit[all]" # everything
71
72
  ```
72
73
 
@@ -281,7 +282,9 @@ names the extra to install.
281
282
  | `deadline` + `interceptors.deadline` | The request budget in a contextvar, and the layer that trims every call to it | `deadline` |
282
283
  | `interceptors.tracing` | OpenTelemetry CLIENT spans and `traceparent` injection | `tracing` |
283
284
  | `interceptors.metrics` | Counters, latency histograms and an in-flight gauge | core¹ |
285
+ | `metrics.GrpcClientMetrics` | The Prometheus collector for them, shaped to join `grpc-server-kit`'s series | `metrics` |
284
286
  | `health.HealthChecker` | Background `grpc.health.v1` probing with per-target backoff | `health` |
287
+ | `dishka.*` | Dishka providers owning the factory's lifecycle, one component per upstream, one pool shared by several | `dishka` |
285
288
  | `protocols` / `validation` / `utils` | Settings seams, target validation, channel and metadata helpers | core |
286
289
 
287
290
  ¹ The metrics interceptor records through `GrpcClientMetricsProtocol` and works
@@ -293,11 +296,12 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
293
296
  | :--- | :--- | :--- |
294
297
  | `health` | `grpcio-health-checking` | `HealthChecker`, health-aware balancing and pooling |
295
298
  | `tracing` | `opentelemetry-api` | `AsyncClientTracingInterceptor` (a pass-through without it) |
296
- | `metrics` | `prometheus-client` | the default metrics backend; a custom registry needs no extra |
299
+ | `metrics` | `prometheus-client` | `GrpcClientMetrics`, the Prometheus collector; a custom registry needs no extra |
297
300
  | `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
298
301
  | `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
302
+ | `dishka` | `dishka` | `grpc_client_providers`, the three providers behind it, and the pool provider several upstreams share |
299
303
  | `observability` | `metrics` + `tracing` | both of the above |
300
- | `all` | `deadline` + `health` + `metrics` + `settings` + `tracing` | everything |
304
+ | `all` | `deadline` + `dishka` + `health` + `metrics` + `settings` + `tracing` | everything |
301
305
 
302
306
  `deadline` is deliberately not part of `observability`: propagating a budget is
303
307
  resilience, not telemetry, and an observability extra should not pull in a
@@ -331,7 +335,8 @@ Full documentation at [bedrock-python.github.io/grpc-client-kit](https://bedrock
331
335
  | [Load balancing](https://bedrock-python.github.io/grpc-client-kit/guide/load-balancing/) | round-robin, random and weighted selection with health filtering |
332
336
  | [Health](https://bedrock-python.github.io/grpc-client-kit/guide/health/) | `grpc.health.v1` probing, per-target backoff and cold starts |
333
337
  | [Observability](https://bedrock-python.github.io/grpc-client-kit/guide/observability/) | log records, CLIENT spans and the metrics an RPC emits |
334
- | [Advanced](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/) | interceptors that re-issue calls, target validation, ownership and DI wiring |
338
+ | [Dependency injection](https://bedrock-python.github.io/grpc-client-kit/guide/dependency-injection/) | the Dishka providers, what they own, one component per upstream, one pool shared by several |
339
+ | [Advanced](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/) | interceptors that re-issue calls, target validation and ownership |
335
340
  | [API reference](https://bedrock-python.github.io/grpc-client-kit/reference/) | generated from the source |
336
341
  | [For AI agents](https://bedrock-python.github.io/grpc-client-kit/agents/) | the whole API surface, the rules that break code when broken and a map of the rest, on one page to hand to a coding assistant |
337
342
 
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0" # x-release-please-version
@@ -0,0 +1,277 @@
1
+ """Dishka providers owning the client lifecycle (``[dishka]`` extra).
2
+
3
+ What an application otherwise writes by hand — build the factory from the settings, start the health
4
+ checker with the container, close the pool with it — lives here as providers, one per thing a
5
+ container hands out, and a bundle registering the three of one upstream::
6
+
7
+ from dishka import make_async_container
8
+
9
+ from grpc_client_kit import GrpcClientFactory
10
+ from grpc_client_kit.dishka import grpc_client_providers
11
+
12
+ container = make_async_container(*grpc_client_providers(settings.users_grpc))
13
+ factory = await container.get(GrpcClientFactory)
14
+ users = factory.create_client(UserStub)
15
+ ...
16
+ await container.close() # stops the checker, closes the pool
17
+
18
+ - `GrpcClientSettingsProvider` holds the settings object and provides it as
19
+ `protocols.GrpcClientSettingsProtocol`, so `settings.BaseGrpcClientSettings` or anything structural
20
+ works.
21
+ - `PrometheusGrpcClientMetricsProvider` provides ``GrpcClientMetricsProtocol | None``: the cached
22
+ `metrics.GrpcClientMetrics` when ``settings.metrics_enabled``, ``None`` otherwise — ``None`` being
23
+ the kit's own spelling of "no metrics", which the factory leaves the layer out for.
24
+ - `AsyncGrpcClientProvider` provides `factory.GrpcClientFactory` in APP scope from an async generator,
25
+ entered when first resolved and left when the container closes.
26
+ - `AsyncChannelPoolProvider` provides `channel.ChannelPool` the same way, for the upstreams that share
27
+ one; a factory with ``shared_pool=True`` borrows it instead of building its own.
28
+
29
+ Several upstreams in one container are Dishka components: every provider takes ``component=``, so
30
+ one bundle per upstream, each in its own component, is one factory per upstream::
31
+
32
+ container = make_async_container(
33
+ *grpc_client_providers(settings.users_grpc, component="users"),
34
+ *grpc_client_providers(settings.orders_grpc, component="orders"),
35
+ )
36
+ users = await container.get(GrpcClientFactory, component="users")
37
+
38
+ Dishka resolves a component's dependencies inside that component, which is why each one carries its
39
+ own three providers; the collector they hand out is the same instance, cached per prefix, and its
40
+ series are told apart by ``service``. A single upstream uses the default component and needs none of
41
+ this.
42
+
43
+ One pool for all of them is `AsyncChannelPoolProvider` in the default component and ``shared_pool=True``
44
+ on every bundle: each factory then borrows that `channel.ChannelPool` instead of building its own, and
45
+ the container closes it once, after the factories, with the pool provider's grace::
46
+
47
+ container = make_async_container(
48
+ AsyncChannelPoolProvider(settings.grpc_pool, metrics=get_grpc_client_metrics()),
49
+ *grpc_client_providers(settings.users_grpc, component="users", shared_pool=True),
50
+ *grpc_client_providers(settings.orders_grpc, component="orders", shared_pool=True),
51
+ )
52
+
53
+ This module needs the ``dishka`` extra (``grpc-client-kit[dishka]``); importing it without raises an
54
+ ``ImportError`` naming the extra.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import logging
60
+ from collections.abc import AsyncIterator
61
+ from typing import Annotated
62
+
63
+ try:
64
+ from dishka import DEFAULT_COMPONENT, FromComponent, Provider, Scope, provide
65
+ except ImportError as exc:
66
+ raise ImportError("Install grpc-client-kit[dishka] (dishka) to use the Dishka providers") from exc
67
+
68
+ from .channel import ChannelPool
69
+ from .factory import GrpcClientFactory
70
+ from .interceptors.metrics import HAS_METRICS
71
+ from .protocols import ChannelPoolSettingsProtocol, GrpcClientMetricsProtocol, GrpcClientSettingsProtocol
72
+
73
+ logger = logging.getLogger(__name__)
74
+
75
+
76
+ class GrpcClientSettingsProvider(Provider):
77
+ """Provide one upstream's settings object as ``GrpcClientSettingsProtocol``.
78
+
79
+ Construct it with anything structurally satisfying the protocol — `settings.BaseGrpcClientSettings`,
80
+ a dataclass, a plain class. The other providers request the settings through the protocol, which
81
+ is what lets a second upstream be a second component rather than a second container.
82
+ """
83
+
84
+ scope = Scope.APP
85
+
86
+ def __init__(self, settings: GrpcClientSettingsProtocol, *, component: str | None = None) -> None:
87
+ super().__init__(component=component)
88
+ self._settings = settings
89
+
90
+ @provide
91
+ def settings(self) -> GrpcClientSettingsProtocol:
92
+ """Provide the settings object as it was given."""
93
+ return self._settings
94
+
95
+
96
+ class PrometheusGrpcClientMetricsProvider(Provider):
97
+ """Provide the registry the factory records into: `metrics.GrpcClientMetrics`, or ``None``.
98
+
99
+ ``None`` when ``settings.metrics_enabled`` is off, and ``None`` with a warning when it is on but
100
+ the ``[metrics]`` extra is absent — the degradation the chain builder applies, so the provider can
101
+ always be registered. The collector comes from `metrics.get_grpc_client_metrics`, one instance per
102
+ prefix on the default registry, so a container rebuilt per test never asks Prometheus to register
103
+ the same series twice.
104
+ """
105
+
106
+ scope = Scope.APP
107
+
108
+ def __init__(self, *, prefix: str | None = None, component: str | None = None) -> None:
109
+ super().__init__(component=component)
110
+ self._prefix = prefix
111
+
112
+ @provide
113
+ def metrics(self, settings: GrpcClientSettingsProtocol) -> GrpcClientMetricsProtocol | None:
114
+ """Provide the collector when metrics are enabled and the extra is installed, ``None`` otherwise."""
115
+ if not settings.metrics_enabled:
116
+ return None
117
+ if not HAS_METRICS:
118
+ logger.warning("Metrics requested but grpc-client-kit[metrics] is not installed; metrics are disabled")
119
+ return None
120
+ from .metrics import get_grpc_client_metrics # noqa: PLC0415 - lazy: needs the [metrics] extra
121
+
122
+ return get_grpc_client_metrics(self._prefix)
123
+
124
+
125
+ class AsyncChannelPoolProvider(Provider):
126
+ """Provide one `channel.ChannelPool` in APP scope, built on first use and drained with the container.
127
+
128
+ The pool several upstreams share: registered in the default component, it is what every
129
+ `AsyncGrpcClientProvider` with ``shared_pool=True`` borrows, and the container closes it once —
130
+ ``close_all(grace=shutdown_grace)``, after the factories that used it have left. Sized from a
131
+ `protocols.ChannelPoolSettingsProtocol` — `settings.BaseChannelPoolSettings` nested at the top of
132
+ the service's settings is one deployment-wide section — and at the pool's own defaults without one.
133
+
134
+ ``metrics`` is the registry the pool statistics are recorded into. It is handed in here, the way
135
+ `factory.GrpcClientFactory` takes it, rather than requested from the container: a container with
136
+ one component per upstream has a registry per component and none in the default one, so there is
137
+ no single seam for the pool to ask. `metrics.get_grpc_client_metrics` returns the instance the
138
+ bundles hand out.
139
+ """
140
+
141
+ scope = Scope.APP
142
+
143
+ def __init__(
144
+ self,
145
+ settings: ChannelPoolSettingsProtocol | None = None,
146
+ *,
147
+ metrics: GrpcClientMetricsProtocol | None = None,
148
+ shutdown_grace: float | None = 5.0,
149
+ component: str | None = None,
150
+ ) -> None:
151
+ super().__init__(component=component)
152
+ self._settings = settings
153
+ self._metrics = metrics
154
+ self._shutdown_grace = shutdown_grace
155
+
156
+ @provide
157
+ async def pool(self) -> AsyncIterator[ChannelPool]:
158
+ """Build the pool from the settings and the registry, and drain it with the container."""
159
+ if self._settings is not None:
160
+ pool = ChannelPool(
161
+ max_channels_per_target=self._settings.max_channels_per_target,
162
+ idle_timeout=self._settings.idle_timeout,
163
+ metrics=self._metrics,
164
+ )
165
+ else:
166
+ pool = ChannelPool(metrics=self._metrics)
167
+ try:
168
+ yield pool
169
+ finally:
170
+ await pool.close_all(grace=self._shutdown_grace)
171
+
172
+
173
+ class AsyncGrpcClientProvider(Provider):
174
+ """Provide `factory.GrpcClientFactory` in APP scope, entered on first use and closed with the container.
175
+
176
+ The factory builds and owns the pool and the health checker: resolving it starts the checker and
177
+ waits for its first pass (``ready_timeout``); closing the container stops the checker and closes
178
+ the pool, giving in-flight RPCs ``shutdown_grace`` — exactly what ``async with factory`` does.
179
+ Clients come from the factory as usual, ``factory.create_client(UserStub)``, and are cheap enough
180
+ to be request-scoped.
181
+
182
+ With ``shared_pool=True`` the factory borrows the `channel.ChannelPool` of the default component —
183
+ an `AsyncChannelPoolProvider` there, or anything providing ``ChannelPool`` — instead of building
184
+ one, and owns nothing but its health checker: closing the container still stops the checker,
185
+ while the pool is closed by whoever provided it, once, after every factory that borrowed it.
186
+ ``shutdown_grace`` then has nothing to apply to; the pool provider carries its own. A container
187
+ with such a factory and no ``ChannelPool`` in the default component is refused when it is built.
188
+ """
189
+
190
+ scope = Scope.APP
191
+
192
+ def __init__(
193
+ self,
194
+ *,
195
+ shutdown_grace: float | None = 5.0,
196
+ ready_timeout: float | None = 10.0,
197
+ shared_pool: bool = False,
198
+ component: str | None = None,
199
+ ) -> None:
200
+ super().__init__(component=component)
201
+ self._shutdown_grace = shutdown_grace
202
+ self._ready_timeout = ready_timeout
203
+ # One key, two sources, chosen here: a Dishka dependency cannot be optional, so a factory that
204
+ # asked for the pool would refuse every container without one.
205
+ self.provide(self.shared_factory if shared_pool else self.factory)
206
+
207
+ async def factory(
208
+ self,
209
+ settings: GrpcClientSettingsProtocol,
210
+ metrics: GrpcClientMetricsProtocol | None,
211
+ ) -> AsyncIterator[GrpcClientFactory]:
212
+ """Build the factory from the settings and the registry, and close it with the container."""
213
+ async with GrpcClientFactory(
214
+ settings=settings,
215
+ shutdown_grace=self._shutdown_grace,
216
+ ready_timeout=self._ready_timeout,
217
+ metrics=metrics,
218
+ ) as factory:
219
+ yield factory
220
+
221
+ async def shared_factory(
222
+ self,
223
+ settings: GrpcClientSettingsProtocol,
224
+ metrics: GrpcClientMetricsProtocol | None,
225
+ pool: Annotated[ChannelPool, FromComponent(DEFAULT_COMPONENT)],
226
+ ) -> AsyncIterator[GrpcClientFactory]:
227
+ """Build the factory on the default component's pool, which it borrows and never closes."""
228
+ async with GrpcClientFactory(
229
+ settings=settings,
230
+ pool=pool,
231
+ ready_timeout=self._ready_timeout,
232
+ metrics=metrics,
233
+ ) as factory:
234
+ yield factory
235
+
236
+
237
+ def grpc_client_providers(
238
+ settings: GrpcClientSettingsProtocol,
239
+ *,
240
+ component: str | None = None,
241
+ metrics_prefix: str | None = None,
242
+ shutdown_grace: float | None = 5.0,
243
+ ready_timeout: float | None = 10.0,
244
+ shared_pool: bool = False,
245
+ ) -> tuple[Provider, ...]:
246
+ """Return the three providers of one upstream, for one-line registration.
247
+
248
+ Args:
249
+ settings: The upstream's settings object; anything satisfying `protocols.GrpcClientSettingsProtocol`.
250
+ component: The Dishka component to register the providers in — one per upstream when a
251
+ container serves several, the default component otherwise.
252
+ metrics_prefix: Prefix for the Prometheus metric names.
253
+ shutdown_grace: Seconds in-flight RPCs get to finish when the container closes; the pool
254
+ provider's own grace applies instead with ``shared_pool``.
255
+ ready_timeout: Seconds the factory waits for the first health check pass when resolved.
256
+ shared_pool: Borrow the ``ChannelPool`` of the default component — an `AsyncChannelPoolProvider`
257
+ registered there — instead of building one for this upstream.
258
+
259
+ Returns:
260
+ The providers, to unpack into ``make_async_container``.
261
+ """
262
+ return (
263
+ GrpcClientSettingsProvider(settings, component=component),
264
+ PrometheusGrpcClientMetricsProvider(prefix=metrics_prefix, component=component),
265
+ AsyncGrpcClientProvider(
266
+ shutdown_grace=shutdown_grace, ready_timeout=ready_timeout, shared_pool=shared_pool, component=component
267
+ ),
268
+ )
269
+
270
+
271
+ __all__ = [
272
+ "AsyncChannelPoolProvider",
273
+ "AsyncGrpcClientProvider",
274
+ "GrpcClientSettingsProvider",
275
+ "PrometheusGrpcClientMetricsProvider",
276
+ "grpc_client_providers",
277
+ ]
@@ -98,6 +98,7 @@ class GrpcClientFactory:
98
98
  pool: ChannelProviderProtocol | None = None,
99
99
  shutdown_grace: float | None = 5.0,
100
100
  ready_timeout: float | None = 10.0,
101
+ metrics: GrpcClientMetricsProtocol | None = None,
101
102
  ) -> None:
102
103
  """Initialize the factory.
103
104
 
@@ -118,6 +119,12 @@ class GrpcClientFactory:
118
119
  that pass every target reads unhealthy, so entering the context without waiting
119
120
  would make the first call of every freshly started pod fail deterministically.
120
121
  ``None`` waits indefinitely; only relevant when health checking is configured.
122
+ metrics: The registry RPC metrics, retries, breaker state and pool statistics are
123
+ recorded into: the default for every client this factory creates, and the one
124
+ the pool it creates reports to. Wins over ``settings.metrics_registry``;
125
+ `create_client(metrics=...)` wins over it per client. The shipped settings
126
+ models carry no registry, a registry being a runtime object, so this is where
127
+ one meets them.
121
128
 
122
129
  Raises:
123
130
  TypeError: If `settings` does not satisfy `GrpcClientSettingsProtocol`. Validated
@@ -135,21 +142,24 @@ class GrpcClientFactory:
135
142
  # reuse the same interceptor instances, or every create_client would mint a fresh channel
136
143
  # identity and a per-request client pattern would open a connection per request.
137
144
  self._chains: dict[tuple[str, str], list[grpc.aio.ClientInterceptor]] = {}
145
+ # The registry every client and the owned pool default to: the explicit one, else the
146
+ # settings' own — the shipped settings models carry none, a registry being a runtime object.
147
+ settings_registry = getattr(settings, "metrics_registry", None) if settings else None
148
+ self._metrics: GrpcClientMetricsProtocol | None = metrics if metrics is not None else settings_registry
138
149
 
139
150
  if pool:
140
151
  self._pool = pool
141
152
  else:
142
153
  self._owns_pool = True
143
- metrics = getattr(settings, "metrics_registry", None) if settings else None
144
154
 
145
155
  if settings and settings.pool:
146
156
  self._pool = ChannelPool(
147
157
  max_channels_per_target=settings.pool.max_channels_per_target,
148
158
  idle_timeout=settings.pool.idle_timeout,
149
- metrics=metrics,
159
+ metrics=self._metrics,
150
160
  )
151
161
  else:
152
- self._pool = ChannelPool(metrics=metrics)
162
+ self._pool = ChannelPool(metrics=self._metrics)
153
163
 
154
164
  # Build and start HealthChecker if configured and we have targets
155
165
  if settings and settings.health_checker and settings.targets:
@@ -252,7 +262,8 @@ class GrpcClientFactory:
252
262
  stub_class: The gRPC stub class to instantiate.
253
263
  target: Optional target override. If not provided, uses target from settings.
254
264
  service_name: Name of the service for observability. Defaults to stub class name.
255
- metrics: Optional metrics registry, overriding `settings.metrics_registry`.
265
+ metrics: Optional metrics registry for this client alone, overriding the factory's
266
+ ``metrics`` and `settings.metrics_registry`.
256
267
  interceptors: Optional list of additional custom interceptors. These instances are
257
268
  shared by every target, unlike the chain the factory builds around them.
258
269
 
@@ -353,8 +364,9 @@ class GrpcClientFactory:
353
364
  def _resolve_metrics_registry(self, metrics: GrpcClientMetricsProtocol | None) -> GrpcClientMetricsProtocol | None:
354
365
  """Pick the registry RPC metrics are recorded into.
355
366
 
356
- An explicit argument wins over `settings.metrics_registry`, which otherwise only fed pool
357
- statistics and left `metrics_enabled` clients recording nothing at all.
367
+ An explicit argument wins over the factory's default — its own ``metrics`` argument, else
368
+ `settings.metrics_registry`, which otherwise only fed pool statistics and left
369
+ `metrics_enabled` clients recording nothing at all.
358
370
 
359
371
  Args:
360
372
  metrics: The registry passed to `create_client`, if any.
@@ -362,13 +374,12 @@ class GrpcClientFactory:
362
374
  Returns:
363
375
  The registry to use, or None if metrics cannot be recorded.
364
376
  """
365
- settings_registry = getattr(self._settings, "metrics_registry", None) if self._settings else None
366
- registry: GrpcClientMetricsProtocol | None = metrics if metrics is not None else settings_registry
377
+ registry: GrpcClientMetricsProtocol | None = metrics if metrics is not None else self._metrics
367
378
 
368
379
  if registry is None and self._settings and self._settings.metrics_enabled:
369
380
  logger.warning(
370
381
  "metrics_enabled is set but no metrics registry is available. "
371
- "Pass 'metrics=' to create_client() or set 'metrics_registry' in settings; "
382
+ "Pass 'metrics=' to the factory or to create_client(), or set 'metrics_registry' in settings; "
372
383
  "gRPC client metrics are disabled."
373
384
  )
374
385
 
@@ -0,0 +1,229 @@
1
+ """The Prometheus collector for the client metrics protocols (``[metrics]`` extra).
2
+
3
+ `GrpcClientMetrics` is the collector the kit's own layers record into. It satisfies
4
+ `protocols.GrpcClientMetricsProtocol` and the two extension protocols the retry layer and the
5
+ circuit breaker look for, `protocols.RetryMetricsProtocol` and
6
+ `protocols.CircuitBreakerMetricsProtocol`, so a factory given one instance measures RPCs, retries,
7
+ breaker state and the pool through it — the extensions are what make a retry storm visible, which the
8
+ base protocol alone cannot show.
9
+
10
+ It is shaped after ``grpc_server_kit.observability.metrics.GrpcServerMetrics`` on purpose: the same
11
+ histogram buckets, and the server's label names in the server's order with ``rpc_type`` added after
12
+ ``method``. A dashboard therefore joins the caller's ``grpc_client_requests_total`` with the callee's
13
+ ``grpc_requests_total`` on ``service``, ``method``, ``status`` and ``grpc_code``, and compares the two
14
+ latency histograms bucket for bucket.
15
+
16
+ Prometheus registers a metric name once per registry, so a second ``GrpcClientMetrics()`` on the
17
+ default registry raises ``ValueError`` — what a test suite runs into when it rebuilds a container.
18
+ `get_grpc_client_metrics` is the answer: it caches one instance per prefix, and it is what the Dishka
19
+ provider calls. A test that wants isolation passes a fresh ``registry=CollectorRegistry()`` instead.
20
+
21
+ This module needs the ``metrics`` extra (``grpc-client-kit[metrics]``), which pulls in
22
+ prometheus-client; importing it without raises an ``ImportError`` naming the extra.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+
29
+ try:
30
+ from prometheus_client import REGISTRY, CollectorRegistry, Counter, Enum, Gauge, Histogram
31
+ except ImportError as exc:
32
+ raise ImportError("Install grpc-client-kit[metrics] (prometheus-client) to use the Prometheus collector") from exc
33
+
34
+ from .interceptors.circuit_breaker import CircuitState
35
+
36
+ # The server kit's buckets, so the two duration histograms compare bucket for bucket (seconds).
37
+ DEFAULT_GRPC_BUCKETS: tuple[float, ...] = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
38
+
39
+ _PREFIX_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
40
+
41
+
42
+ def _make_metric_name(name: str, prefix: str | None) -> str:
43
+ """Prefix a metric name, refusing a prefix that is not a valid metric name fragment.
44
+
45
+ Args:
46
+ name: The metric name without a prefix.
47
+ prefix: The prefix to put in front of it, or None for none.
48
+
49
+ Returns:
50
+ The name Prometheus is asked to register.
51
+
52
+ Raises:
53
+ ValueError: If the prefix is not a valid metric name fragment.
54
+ """
55
+ if not prefix:
56
+ return name
57
+ if not _PREFIX_PATTERN.match(prefix):
58
+ raise ValueError(
59
+ f"Invalid metric prefix: {prefix!r}. Prefixes must start with a letter or underscore and "
60
+ "contain only letters, numbers, or underscores."
61
+ )
62
+ return f"{prefix}_{name}"
63
+
64
+
65
+ class GrpcClientMetrics:
66
+ """gRPC client metrics as Prometheus collectors, one instance per registry.
67
+
68
+ Metrics:
69
+ - ``grpc_client_requests_total`` (Counter) — labels: service, method, rpc_type, status, grpc_code
70
+ - ``grpc_client_request_duration_seconds`` (Histogram) — labels: service, method, rpc_type
71
+ - ``grpc_client_requests_in_flight`` (Gauge) — labels: service, method, rpc_type
72
+ - ``grpc_client_pool_channels`` (Gauge) — pooled channels
73
+ - ``grpc_client_pool_targets`` (Gauge) — distinct addresses the pooled channels lead to
74
+ - ``grpc_client_retries_total`` (Counter) — labels: service, method, grpc_code
75
+ - ``grpc_client_circuit_breaker_state`` (Enum: closed, open, half-open) — labels: method
76
+ - ``grpc_client_circuit_breaker_rejections_total`` (Counter) — labels: method
77
+
78
+ The label values are recorded as the kit's layers hand them over: ``service`` and ``method``
79
+ are the short names parsed from the method path for requests, and the full method path for
80
+ the breaker; ``status`` is ``success``, ``error``, ``rejected`` or ``cancelled``.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ prefix: str | None = None,
86
+ buckets: tuple[float, ...] = DEFAULT_GRPC_BUCKETS,
87
+ registry: CollectorRegistry | None = None,
88
+ ) -> None:
89
+ """Register the collectors.
90
+
91
+ Args:
92
+ prefix: Optional metric name prefix (``myapp`` gives ``myapp_grpc_client_requests_total``).
93
+ buckets: Histogram buckets for the request duration, in seconds.
94
+ registry: The registry to register with; the process-wide default when None.
95
+
96
+ Raises:
97
+ ValueError: If the prefix is invalid, or a metric of the same name is already
98
+ registered there — see `get_grpc_client_metrics` for the second instance.
99
+ """
100
+ self.buckets: tuple[float, ...] = tuple(buckets)
101
+ reg = registry if registry is not None else REGISTRY
102
+ self.requests_total = Counter(
103
+ _make_metric_name("grpc_client_requests_total", prefix),
104
+ "Total number of gRPC client requests",
105
+ ["service", "method", "rpc_type", "status", "grpc_code"],
106
+ registry=reg,
107
+ )
108
+ self.request_duration = Histogram(
109
+ _make_metric_name("grpc_client_request_duration_seconds", prefix),
110
+ "gRPC client request duration in seconds, retries and their backoff included",
111
+ ["service", "method", "rpc_type"],
112
+ buckets=list(buckets),
113
+ registry=reg,
114
+ )
115
+ self.requests_in_flight = Gauge(
116
+ _make_metric_name("grpc_client_requests_in_flight", prefix),
117
+ "gRPC client requests currently in progress",
118
+ ["service", "method", "rpc_type"],
119
+ registry=reg,
120
+ )
121
+ self.pool_channels = Gauge(
122
+ _make_metric_name("grpc_client_pool_channels", prefix),
123
+ "Channels currently held by the channel pool",
124
+ registry=reg,
125
+ )
126
+ self.pool_targets = Gauge(
127
+ _make_metric_name("grpc_client_pool_targets", prefix),
128
+ "Distinct target addresses the pooled channels lead to",
129
+ registry=reg,
130
+ )
131
+ self.retries_total = Counter(
132
+ _make_metric_name("grpc_client_retries_total", prefix),
133
+ "Total number of scheduled gRPC client retries",
134
+ ["service", "method", "grpc_code"],
135
+ registry=reg,
136
+ )
137
+ self.circuit_breaker_state = Enum(
138
+ _make_metric_name("grpc_client_circuit_breaker_state", prefix),
139
+ "State of the circuit breaker of a method",
140
+ ["method"],
141
+ states=[state.value for state in CircuitState],
142
+ registry=reg,
143
+ )
144
+ self.circuit_breaker_rejections_total = Counter(
145
+ _make_metric_name("grpc_client_circuit_breaker_rejections_total", prefix),
146
+ "Total number of gRPC client calls refused locally by an open circuit breaker",
147
+ ["method"],
148
+ registry=reg,
149
+ )
150
+
151
+ def record_request(
152
+ self,
153
+ service: str,
154
+ method: str,
155
+ rpc_type: str,
156
+ status: str,
157
+ grpc_code: str,
158
+ duration: float,
159
+ ) -> None:
160
+ """Record a completed gRPC request."""
161
+ self.requests_total.labels(
162
+ service=service, method=method, rpc_type=rpc_type, status=status, grpc_code=grpc_code
163
+ ).inc()
164
+ self.request_duration.labels(service=service, method=method, rpc_type=rpc_type).observe(duration)
165
+
166
+ def record_inflight_delta(self, service: str, method: str, rpc_type: str, delta: int) -> None:
167
+ """Move the in-flight gauge by ``delta``."""
168
+ self.requests_in_flight.labels(service=service, method=method, rpc_type=rpc_type).inc(delta)
169
+
170
+ def record_pool_stats(self, active_channels: int, idle_targets: int) -> None:
171
+ """Record the channel pool's size."""
172
+ self.pool_channels.set(active_channels)
173
+ self.pool_targets.set(idle_targets)
174
+
175
+ def record_retry(self, service: str, method: str, attempt: int, grpc_code: str) -> None:
176
+ """Count one scheduled retry; the attempt number is not a label, it would multiply the series."""
177
+ self.retries_total.labels(service=service, method=method, grpc_code=grpc_code).inc()
178
+
179
+ def record_circuit_state(self, method: str, state: str) -> None:
180
+ """Record a circuit state transition."""
181
+ self.circuit_breaker_state.labels(method=method).state(state)
182
+
183
+ def record_circuit_rejection(self, method: str) -> None:
184
+ """Count one call refused locally by an open circuit."""
185
+ self.circuit_breaker_rejections_total.labels(method=method).inc()
186
+
187
+
188
+ _CLIENT_METRICS_CACHE: dict[str | None, GrpcClientMetrics] = {}
189
+
190
+
191
+ def get_grpc_client_metrics(
192
+ prefix: str | None = None,
193
+ buckets: tuple[float, ...] | None = None,
194
+ ) -> GrpcClientMetrics:
195
+ """Get (or lazily create) the cached ``GrpcClientMetrics`` for a prefix, on the default registry.
196
+
197
+ Caching by prefix is what lets a container be rebuilt — a test suite does it per test —
198
+ without Prometheus refusing the second registration of the same series.
199
+
200
+ Args:
201
+ prefix: The metric name prefix the instance was, or is, created with.
202
+ buckets: Histogram buckets for a new instance; None takes the default.
203
+
204
+ Returns:
205
+ The one instance for that prefix.
206
+
207
+ Raises:
208
+ ValueError: If the prefix is already cached with DIFFERENT buckets — silently returning
209
+ the old instance would record latencies into the wrong histogram bounds with no error.
210
+ """
211
+ requested = tuple(buckets) if buckets is not None else DEFAULT_GRPC_BUCKETS
212
+ cached = _CLIENT_METRICS_CACHE.get(prefix)
213
+ if cached is not None:
214
+ if buckets is not None and requested != cached.buckets:
215
+ raise ValueError(
216
+ f"GrpcClientMetrics for prefix {prefix!r} already exists with buckets "
217
+ f"{cached.buckets}; cannot re-create it with {requested}"
218
+ )
219
+ return cached
220
+ metrics = GrpcClientMetrics(prefix=prefix, buckets=requested)
221
+ _CLIENT_METRICS_CACHE[prefix] = metrics
222
+ return metrics
223
+
224
+
225
+ __all__ = [
226
+ "DEFAULT_GRPC_BUCKETS",
227
+ "GrpcClientMetrics",
228
+ "get_grpc_client_metrics",
229
+ ]
@@ -44,11 +44,14 @@ deadline = [
44
44
  settings = [
45
45
  "pydantic>=2.0.0,<3.0",
46
46
  ]
47
+ dishka = [
48
+ "dishka>=1.4.0,<2.0.0",
49
+ ]
47
50
  observability = [
48
51
  "grpc-client-kit[metrics,tracing]",
49
52
  ]
50
53
  all = [
51
- "grpc-client-kit[deadline,health,metrics,settings,tracing]",
54
+ "grpc-client-kit[deadline,dishka,health,metrics,settings,tracing]",
52
55
  ]
53
56
 
54
57
  [dependency-groups]
@@ -1 +0,0 @@
1
- __version__ = "0.2.0" # x-release-please-version
File without changes