grpc-client-kit 0.1.1__tar.gz → 0.2.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 (34) hide show
  1. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/CHANGELOG.md +14 -0
  2. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/PKG-INFO +20 -6
  3. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/README.md +16 -5
  4. grpc_client_kit-0.2.0/grpc_client_kit/__version__.py +1 -0
  5. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/factory.py +27 -6
  6. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/base.py +187 -65
  7. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/tracing.py +9 -5
  8. grpc_client_kit-0.2.0/grpc_client_kit/settings.py +418 -0
  9. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/pyproject.toml +5 -1
  10. grpc_client_kit-0.1.1/grpc_client_kit/__version__.py +0 -1
  11. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/.gitignore +0 -0
  12. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/LICENSE +0 -0
  13. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/__init__.py +0 -0
  14. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/balancers.py +0 -0
  15. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/channel.py +0 -0
  16. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/client.py +0 -0
  17. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/config.py +0 -0
  18. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/deadline.py +0 -0
  19. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/errors.py +0 -0
  20. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/health.py +0 -0
  21. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/__init__.py +0 -0
  22. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/circuit_breaker.py +0 -0
  23. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/client_logging.py +0 -0
  24. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/context.py +0 -0
  25. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/deadline.py +0 -0
  26. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/metrics.py +0 -0
  27. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/outlier.py +0 -0
  28. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/retry.py +0 -0
  29. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/timeout.py +0 -0
  30. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/interceptors/wait_for_ready.py +0 -0
  31. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/protocols.py +0 -0
  32. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/py.typed +0 -0
  33. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/utils.py +0 -0
  34. {grpc_client_kit-0.1.1 → grpc_client_kit-0.2.0}/grpc_client_kit/validation.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [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
+
5
+
6
+ ### Features
7
+
8
+ * settings models for the environment, mirroring the dataclasses ([edffb5f](https://github.com/bedrock-python/grpc-client-kit/commit/edffb5fdcf934cecd5788a99e37064133383d9cd))
9
+
10
+ ## [0.1.2](https://github.com/bedrock-python/grpc-client-kit/compare/grpc-client-kit-v0.1.1...grpc-client-kit-v0.1.2) (2026-09-07)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * an around_call teardown cannot change a call's outcome, and shares its setup's context ([#20](https://github.com/bedrock-python/grpc-client-kit/issues/20)) ([27b5c83](https://github.com/bedrock-python/grpc-client-kit/commit/27b5c8348e7430169d86059cbde36218b206d0f1))
16
+
3
17
  ## [0.1.1](https://github.com/bedrock-python/grpc-client-kit/compare/grpc-client-kit-v0.1.0...grpc-client-kit-v0.1.1) (2026-09-06)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: grpc-client-kit
3
- Version: 0.1.1
3
+ Version: 0.2.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/
@@ -226,6 +226,7 @@ Requires-Dist: deadline-budget<1.0.0,>=0.1.0; extra == 'all'
226
226
  Requires-Dist: grpcio-health-checking<2.0.0,>=1.78.0; extra == 'all'
227
227
  Requires-Dist: opentelemetry-api<2.0.0,>=1.30.0; extra == 'all'
228
228
  Requires-Dist: prometheus-client<1.0.0,>=0.20.0; extra == 'all'
229
+ Requires-Dist: pydantic<3.0,>=2.0.0; extra == 'all'
229
230
  Provides-Extra: deadline
230
231
  Requires-Dist: deadline-budget<1.0.0,>=0.1.0; extra == 'deadline'
231
232
  Provides-Extra: health
@@ -235,6 +236,8 @@ Requires-Dist: prometheus-client<1.0.0,>=0.20.0; extra == 'metrics'
235
236
  Provides-Extra: observability
236
237
  Requires-Dist: opentelemetry-api<2.0.0,>=1.30.0; extra == 'observability'
237
238
  Requires-Dist: prometheus-client<1.0.0,>=0.20.0; extra == 'observability'
239
+ Provides-Extra: settings
240
+ Requires-Dist: pydantic<3.0,>=2.0.0; extra == 'settings'
238
241
  Provides-Extra: tracing
239
242
  Requires-Dist: opentelemetry-api<2.0.0,>=1.30.0; extra == 'tracing'
240
243
  Description-Content-Type: text/markdown
@@ -307,6 +310,7 @@ pip install "grpc-client-kit[health]" # + grpc.health.v1 monitoring
307
310
  pip install "grpc-client-kit[metrics,tracing]" # + Prometheus + OpenTelemetry
308
311
  pip install "grpc-client-kit[observability]" # + metrics and tracing together
309
312
  pip install "grpc-client-kit[deadline]" # + request budget propagation
313
+ pip install "grpc-client-kit[settings]" # + pydantic settings models
310
314
  pip install "grpc-client-kit[all]" # everything
311
315
  ```
312
316
 
@@ -362,7 +366,11 @@ Any object carrying the fields of `GrpcClientSettingsProtocol` works —
362
366
  a pydantic model, a dataclass, a plain class. Everything optional
363
367
  (`credentials`, `options`, `compression`, `metrics_registry`,
364
368
  `sensitive_headers`) is read through `getattr`, so a minimal settings object
365
- stays valid. See the
369
+ stays valid. Or take the shipped one: `grpc_client_kit.settings.BaseGrpcClientSettings`
370
+ (the `[settings]` extra) is that shape written down once — plain pydantic
371
+ models with the kit's own defaults and bounds, one per upstream, nested under
372
+ your service's `BaseSettings` for loading from the environment, and
373
+ `to_config()` on every section for a hand-built chain. See the
366
374
  [configuration guide](https://bedrock-python.github.io/grpc-client-kit/guide/configuration/)
367
375
  for the full field list.
368
376
 
@@ -463,9 +471,13 @@ class FailureCounter(AsyncAroundClientInterceptor):
463
471
 
464
472
  Code before the `yield` runs before the RPC exists, so that is where call
465
473
  details are rewritten and where raising refuses a call outright. Code after it
466
- runs once the call is over, whichever of the four kinds it was. Layers that
467
- re-issue a call — retries — subclass `AsyncClientInterceptor` and issue it
468
- themselves; see the
474
+ runs once the call is over, whichever of the four kinds it was, and cannot
475
+ change how it ended: what the teardown raises is logged and dropped. Both sides
476
+ of the `yield` run in one `contextvars.Context`, so `token = VAR.set(...)`
477
+ before it and `VAR.reset(token)` after it is a supported way to scope a request
478
+ id — or an OpenTelemetry context — to a single call. Layers that re-issue a
479
+ call — retries — subclass `AsyncClientInterceptor` and issue it themselves; see
480
+ the
469
481
  [advanced guide](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/).
470
482
 
471
483
  **`INTERNAL` is not retryable by default.** `DEFAULT_RETRYABLE_CODES` holds
@@ -507,6 +519,7 @@ names the extra to install.
507
519
  | `channel.ChannelPool` | Channels pooled by full identity, idle eviction, health flags | core |
508
520
  | `client.GrpcClient` | Stub factory: target selection + channel acquisition | core |
509
521
  | `factory.GrpcClientFactory` | Settings → pool, balancer, health checker, per-target chains | core |
522
+ | `settings.BaseGrpcClientSettings` | The settings shape as pydantic models mirroring the dataclasses, `to_config()` on each | `settings` |
510
523
  | `balancers.*` | Round-robin / random / weighted selection with health filtering | core |
511
524
  | `interceptors.*` | Context, logging, timeout, wait-for-ready, retry, circuit breaker + chain builder | core |
512
525
  | `deadline` + `interceptors.deadline` | The request budget in a contextvar, and the layer that trims every call to it | `deadline` |
@@ -526,8 +539,9 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
526
539
  | `tracing` | `opentelemetry-api` | `AsyncClientTracingInterceptor` (a pass-through without it) |
527
540
  | `metrics` | `prometheus-client` | the default metrics backend; a custom registry needs no extra |
528
541
  | `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
542
+ | `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
529
543
  | `observability` | `metrics` + `tracing` | both of the above |
530
- | `all` | `deadline` + `health` + `metrics` + `tracing` | everything |
544
+ | `all` | `deadline` + `health` + `metrics` + `settings` + `tracing` | everything |
531
545
 
532
546
  `deadline` is deliberately not part of `observability`: propagating a budget is
533
547
  resilience, not telemetry, and an observability extra should not pull in a
@@ -66,6 +66,7 @@ pip install "grpc-client-kit[health]" # + grpc.health.v1 monitoring
66
66
  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
+ pip install "grpc-client-kit[settings]" # + pydantic settings models
69
70
  pip install "grpc-client-kit[all]" # everything
70
71
  ```
71
72
 
@@ -121,7 +122,11 @@ Any object carrying the fields of `GrpcClientSettingsProtocol` works —
121
122
  a pydantic model, a dataclass, a plain class. Everything optional
122
123
  (`credentials`, `options`, `compression`, `metrics_registry`,
123
124
  `sensitive_headers`) is read through `getattr`, so a minimal settings object
124
- stays valid. See the
125
+ stays valid. Or take the shipped one: `grpc_client_kit.settings.BaseGrpcClientSettings`
126
+ (the `[settings]` extra) is that shape written down once — plain pydantic
127
+ models with the kit's own defaults and bounds, one per upstream, nested under
128
+ your service's `BaseSettings` for loading from the environment, and
129
+ `to_config()` on every section for a hand-built chain. See the
125
130
  [configuration guide](https://bedrock-python.github.io/grpc-client-kit/guide/configuration/)
126
131
  for the full field list.
127
132
 
@@ -222,9 +227,13 @@ class FailureCounter(AsyncAroundClientInterceptor):
222
227
 
223
228
  Code before the `yield` runs before the RPC exists, so that is where call
224
229
  details are rewritten and where raising refuses a call outright. Code after it
225
- runs once the call is over, whichever of the four kinds it was. Layers that
226
- re-issue a call — retries — subclass `AsyncClientInterceptor` and issue it
227
- themselves; see the
230
+ runs once the call is over, whichever of the four kinds it was, and cannot
231
+ change how it ended: what the teardown raises is logged and dropped. Both sides
232
+ of the `yield` run in one `contextvars.Context`, so `token = VAR.set(...)`
233
+ before it and `VAR.reset(token)` after it is a supported way to scope a request
234
+ id — or an OpenTelemetry context — to a single call. Layers that re-issue a
235
+ call — retries — subclass `AsyncClientInterceptor` and issue it themselves; see
236
+ the
228
237
  [advanced guide](https://bedrock-python.github.io/grpc-client-kit/guide/advanced/).
229
238
 
230
239
  **`INTERNAL` is not retryable by default.** `DEFAULT_RETRYABLE_CODES` holds
@@ -266,6 +275,7 @@ names the extra to install.
266
275
  | `channel.ChannelPool` | Channels pooled by full identity, idle eviction, health flags | core |
267
276
  | `client.GrpcClient` | Stub factory: target selection + channel acquisition | core |
268
277
  | `factory.GrpcClientFactory` | Settings → pool, balancer, health checker, per-target chains | core |
278
+ | `settings.BaseGrpcClientSettings` | The settings shape as pydantic models mirroring the dataclasses, `to_config()` on each | `settings` |
269
279
  | `balancers.*` | Round-robin / random / weighted selection with health filtering | core |
270
280
  | `interceptors.*` | Context, logging, timeout, wait-for-ready, retry, circuit breaker + chain builder | core |
271
281
  | `deadline` + `interceptors.deadline` | The request budget in a contextvar, and the layer that trims every call to it | `deadline` |
@@ -285,8 +295,9 @@ with any backend you pass. The `[metrics]` extra only supplies the usual one.
285
295
  | `tracing` | `opentelemetry-api` | `AsyncClientTracingInterceptor` (a pass-through without it) |
286
296
  | `metrics` | `prometheus-client` | the default metrics backend; a custom registry needs no extra |
287
297
  | `deadline` | `deadline-budget` | `AsyncDeadlineBudgetInterceptor` (the layer is skipped without it) |
298
+ | `settings` | `pydantic` | `BaseGrpcClientSettings` and the section models |
288
299
  | `observability` | `metrics` + `tracing` | both of the above |
289
- | `all` | `deadline` + `health` + `metrics` + `tracing` | everything |
300
+ | `all` | `deadline` + `health` + `metrics` + `settings` + `tracing` | everything |
290
301
 
291
302
  `deadline` is deliberately not part of `observability`: propagating a budget is
292
303
  resilience, not telemetry, and an observability extra should not pull in a
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0" # x-release-please-version
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ from dataclasses import fields
4
5
  from typing import TYPE_CHECKING, Any
5
6
 
6
7
  import grpc.aio
@@ -8,7 +9,7 @@ import grpc.aio
8
9
  from .balancers import LoadBalancer, LoadBalancerConfig, LoadBalancingStrategy, create_balancer
9
10
  from .channel import ChannelPool
10
11
  from .client import GrpcClient
11
- from .config import GrpcClientConfig
12
+ from .config import ConnectivityConfig, GrpcClientConfig
12
13
  from .interceptors import (
13
14
  CircuitBreakerConfig,
14
15
  DeadlineBudgetConfig,
@@ -35,6 +36,10 @@ logger = logging.getLogger(__name__)
35
36
 
36
37
  _MISSING_MESSAGE = "Install grpc-client-kit[health] (grpcio-health-checking) to use gRPC health checking"
37
38
 
39
+ # What a duck-typed `connectivity` block may carry: the dataclass's own fields, so a knob added there
40
+ # is read from settings without this module having to hear about it.
41
+ _CONNECTIVITY_FIELDS = tuple(field.name for field in fields(ConnectivityConfig))
42
+
38
43
 
39
44
  def _validate_settings(settings: object | None) -> None:
40
45
  """Refuse a settings object that cannot satisfy the factory, while the mistake is visible.
@@ -306,6 +311,10 @@ class GrpcClientFactory:
306
311
  metrics_registry=metrics_registry,
307
312
  service_name=actual_service_name,
308
313
  sensitive_headers=getattr(self._settings, "sensitive_headers", None),
314
+ sensitive_methods=getattr(self._settings, "sensitive_methods", None),
315
+ sensitive_patterns=getattr(self._settings, "sensitive_patterns", None),
316
+ log_request_payload=getattr(self._settings, "log_request_payload", False),
317
+ log_response_payload=getattr(self._settings, "log_response_payload", False),
309
318
  enable_method_label=getattr(self._settings, "enable_method_label", True),
310
319
  success_log_level=getattr(self._settings, "success_log_level", logging.INFO),
311
320
  )
@@ -371,8 +380,8 @@ class GrpcClientFactory:
371
380
  Channel construction settings are optional blocks (see `protocols.GrpcChannelExtrasProtocol`)
372
381
  and are read with `getattr`, so a settings model that declares none of them still satisfies
373
382
  the factory. ``connectivity`` joins them on the same terms: a settings object that carries a
374
- `config.ConnectivityConfig` under that name has its channels tuned by it, and one that does
375
- not is left with gRPC's defaults.
383
+ `config.ConnectivityConfig` — or a block with its fields — under that name has its channels
384
+ tuned by it, and one that does not is left with gRPC's defaults.
376
385
  """
377
386
  if self._settings:
378
387
  actual_target = target or self._settings.target
@@ -380,7 +389,7 @@ class GrpcClientFactory:
380
389
  credentials = getattr(self._settings, "credentials", None)
381
390
  options = getattr(self._settings, "options", None)
382
391
  compression = getattr(self._settings, "compression", None)
383
- connectivity = getattr(self._settings, "connectivity", None)
392
+ connectivity = self._build_connectivity_config(getattr(self._settings, "connectivity", None))
384
393
  else:
385
394
  actual_target = target
386
395
  insecure = False
@@ -422,11 +431,23 @@ class GrpcClientFactory:
422
431
  health_checker=self._health_checker,
423
432
  )
424
433
 
434
+ def _build_connectivity_config(self, s: Any | None) -> ConnectivityConfig | None:
435
+ """Build ConnectivityConfig from an optional settings block (duck-typed).
436
+
437
+ A ready `config.ConnectivityConfig` is taken as it is. Any other block is read field by
438
+ field, and a field it does not carry keeps the dataclass's own default.
439
+ """
440
+ if not s:
441
+ return None
442
+ if isinstance(s, ConnectivityConfig):
443
+ return s
444
+ return ConnectivityConfig(**{name: getattr(s, name) for name in _CONNECTIVITY_FIELDS if hasattr(s, name)})
445
+
425
446
  def _build_timeout_config(self, s: TimeoutSettingsProtocol | None) -> TimeoutConfig | None:
426
- """Build TimeoutConfig from settings protocol."""
447
+ """Build TimeoutConfig from settings protocol; per-method budgets are read when the block has them."""
427
448
  if not s:
428
449
  return None
429
- return TimeoutConfig(default=s.default)
450
+ return TimeoutConfig(default=s.default, per_method=dict(getattr(s, "per_method", None) or {}))
430
451
 
431
452
  def _build_retry_config(
432
453
  self, s: RetrySettingsProtocol | None, metrics: GrpcClientMetricsProtocol | None = None
@@ -33,6 +33,15 @@ Writing one::
33
33
 
34
34
  That one generator covers all four RPC kinds: for a streaming response the base class holds it open
35
35
  until the last item has been delivered, so the ``except`` branch sees a mid-stream failure too.
36
+
37
+ Two guarantees the base class gives it, both of them measured rather than assumed. Whatever the
38
+ generator raises *after* the ``yield`` is logged and dropped: an interceptor is observability, and a
39
+ metrics push or a log write that fails must not turn a call the server answered ``OK`` into an
40
+ exception for the application — nor vanish into the event loop's exception handler, which is where
41
+ it used to go for a streaming request. And the code before the ``yield`` and the code after it run
42
+ in one `contextvars.Context`, so a token minted in the setup can be reset in the teardown, on every
43
+ one of the four kinds and not only on the one whose teardown happens to run in the task that started
44
+ the call. `_AroundScope` is where both live.
36
45
  """
37
46
 
38
47
  from __future__ import annotations
@@ -40,24 +49,41 @@ from __future__ import annotations
40
49
  import abc
41
50
  import asyncio
42
51
  import contextlib
52
+ import contextvars
43
53
  import functools
44
- from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
54
+ import logging
55
+ from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Iterable
45
56
  from dataclasses import dataclass, field
46
57
  from typing import Any, Literal
47
58
 
48
59
  import grpc.aio
49
60
 
61
+ logger = logging.getLogger(__name__)
62
+
50
63
  # Outcome observers for deferred calls; referenced here so the loop cannot collect them mid-flight.
51
64
  _BACKGROUND_TASKS: set[asyncio.Task[None]] = set()
52
65
 
53
66
 
54
- def _spawn_background(coro: Awaitable[None]) -> None:
55
- """Run an outcome observer as a task the event loop cannot garbage-collect early."""
56
- task = asyncio.ensure_future(coro)
67
+ def _spawn_background(coro: Coroutine[Any, Any, None], context: contextvars.Context | None = None) -> None:
68
+ """Run an outcome observer as a task the event loop cannot garbage-collect early.
69
+
70
+ Args:
71
+ coro: The observer to run.
72
+ context: The context to step it in. `None` copies the current one, which is what a task
73
+ does by default; an around scope hands over the context its setup ran in, so that the
74
+ teardown it eventually reaches is allowed to reset what the setup set.
75
+ """
76
+ task = asyncio.get_running_loop().create_task(coro, context=context)
57
77
  _BACKGROUND_TASKS.add(task)
58
78
  task.add_done_callback(_BACKGROUND_TASKS.discard)
59
79
 
60
80
 
81
+ def _running_context() -> contextvars.Context | None:
82
+ """The context the task running right now was started in, or None outside a task."""
83
+ task = asyncio.current_task()
84
+ return task.get_context() if task is not None else None
85
+
86
+
61
87
  type RpcType = Literal["unary_unary", "unary_stream", "stream_unary", "stream_stream"]
62
88
 
63
89
  # What a logical interceptor is handed as a continuation: ``(call_details, request) -> Call``.
@@ -236,6 +262,16 @@ class AsyncAroundClientInterceptor(AsyncClientInterceptor, abc.ABC):
236
262
  ordinary ``try/except`` around it covers the whole call. Refusing a call is a matter of raising
237
263
  before the ``yield``: the RPC is then never created. Swallowing an exception is not supported —
238
264
  the call has already failed by then, and there is no response to return in its place.
265
+
266
+ Raising *after* the ``yield`` is not a way to fail a call either. The outcome is decided by
267
+ then, so whatever the teardown raises is logged at ERROR against the method and dropped —
268
+ identically for all four RPC kinds, and whether the call had succeeded or failed.
269
+
270
+ The two sides of the ``yield`` share one `contextvars.Context`. The pair that scopes something
271
+ for the length of a call is therefore legal here on every kind — ``token = VAR.set(...)`` before
272
+ the yield and ``VAR.reset(token)`` after it, or OpenTelemetry's ``attach`` and ``detach`` — and
273
+ what the setup sets is visible to the layers below. `_AroundScope` is where that is arranged,
274
+ and what it costs.
239
275
  """
240
276
 
241
277
  @abc.abstractmethod
@@ -249,113 +285,199 @@ class AsyncAroundClientInterceptor(AsyncClientInterceptor, abc.ABC):
249
285
 
250
286
  async def intercept(self, call: ClientCall) -> Any:
251
287
  """Issue the call inside `around_call` (base plumbing; subclasses override the generator)."""
252
- around = self._around(call)
253
- await around.__aenter__()
254
-
255
- if call.response_streaming:
288
+ deferred_teardown = call.request_streaming or call.response_streaming
289
+ scope = _AroundScope(self._around(call), call.method, pin_context=deferred_teardown)
290
+
291
+ if not deferred_teardown:
292
+ # Setup, call and teardown are this one coroutine, so the generator already runs start
293
+ # to finish in a single context and there is nothing to pin. The kind that dominates
294
+ # every chain pays no task for a guarantee it satisfies for free.
295
+ await scope.enter()
256
296
  try:
257
- stream = await call.invoke_stream()
297
+ response = await call.invoke_unary()
258
298
  except BaseException as error:
259
- await around.__aexit__(type(error), error, error.__traceback__)
299
+ await scope.close(error)
260
300
  raise
301
+ await scope.close(None)
302
+ return response
303
+
304
+ # The RPC is created inside the scope's context, not only the setup: what the setup sets
305
+ # has to be visible to the layers below, or scoping a value for the length of a call would
306
+ # be legal and pointless at the same time.
307
+ started = await scope.in_context(_open_call(scope, call))
308
+
309
+ if call.response_streaming:
261
310
  # The teardown has to outlive this method: the call is not over until the last item is.
262
311
  # It must also not depend on the consumer finishing the iteration — a cancelled or
263
312
  # abandoned call fires its done callback, and that closes the teardown deterministically
264
313
  # instead of waiting for garbage collection.
265
- finalizer = _AroundFinalizer(around)
266
- _finalize_when_done(call.underlying_call, finalizer)
267
- return _closing_stream(finalizer, stream)
314
+ _finalize_when_done(call.underlying_call, scope)
315
+ return _closing_stream(scope, started)
268
316
 
269
- if call.request_streaming:
270
- try:
271
- deferred = await call.invoke_unary()
272
- except BaseException as error:
273
- await around.__aexit__(type(error), error, error.__traceback__)
274
- raise
275
- # The outcome of a streaming-request call arrives after this task has returned (see
276
- # `invoke_unary`), so the teardown runs from an observer instead of from here.
277
- finalizer = _AroundFinalizer(around)
278
- _spawn_background(_finalize_unary_outcome(call, deferred, finalizer))
279
- return deferred
317
+ # The outcome of a streaming-request call arrives after this task has returned (see
318
+ # `invoke_unary`), so the teardown runs from an observer instead of from here.
319
+ _spawn_background(_finalize_unary_outcome(call, started, scope), context=scope.context)
320
+ return started
280
321
 
281
- try:
282
- response = await call.invoke_unary()
283
- except BaseException as error:
284
- await around.__aexit__(type(error), error, error.__traceback__)
285
- raise
286
322
 
287
- await around.__aexit__(None, None, None)
288
- return response
323
+ async def _open_call(scope: _AroundScope, call: ClientCall) -> Any:
324
+ """Run the setup and issue the RPC, both inside the scope's context.
289
325
 
326
+ Args:
327
+ scope: The around scope wrapping this call.
328
+ call: The call to issue.
290
329
 
291
- class _AroundFinalizer:
292
- """Closes one around context exactly once, from whichever side finishes first.
330
+ Returns:
331
+ The response iterator of a streaming response, or the `Call` of a streaming request.
293
332
 
294
- The iterator path and the call's done callback both race to report the outcome; whichever
295
- arrives second must find the work already done rather than throw a second exception into a
296
- generator that has already stopped.
333
+ Raises:
334
+ BaseException: Whatever the setup raised — refusing the call, with no teardown, which is
335
+ what raising before the ``yield`` has always meant — or whatever issuing the call
336
+ raised, after the teardown has been given it.
297
337
  """
338
+ await scope.enter()
339
+ try:
340
+ if call.response_streaming:
341
+ return await call.invoke_stream()
342
+ return await call.invoke_unary()
343
+ except BaseException as error:
344
+ await scope.close(error)
345
+ raise
346
+
347
+
348
+ class _AroundScope:
349
+ """One `around_call` generator: closed exactly once, in the context its setup ran in.
350
+
351
+ Two things a bare pair of ``__aenter__`` / ``__aexit__`` calls does not give the seam, both
352
+ measured against a live server rather than inferred.
353
+
354
+ **The two sides of the ``yield`` have to share a context.** For three of the four RPC kinds the
355
+ teardown is stepped from a different task than the setup — whichever one drains the response
356
+ stream, or the observer that awaits a deferred outcome — and `contextvars` compares contexts by
357
+ identity, so a token minted before the ``yield`` cannot be reset after it. `asyncio.Task` takes
358
+ a ``context=`` and *enters* that object instead of copying it, so one context of this scope's
359
+ own, entered by both sides, is what makes the set/reset pair legal on every kind. Unary-unary
360
+ pins nothing: its setup, call and teardown are already one coroutine, and a task per call per
361
+ layer is a real cost on the busiest path in the kit for a guarantee already held there.
362
+
363
+ **A failing teardown is not an outcome.** An interceptor is observability. Something that fails
364
+ after the call has finished must not replace the response the server sent, must not truncate a
365
+ stream, and must not vanish into the event loop's exception handler either — which were the
366
+ three different fates one raising teardown had, depending only on the kind of call it wrapped.
367
+ """
368
+
369
+ __slots__ = ("_around", "_closed", "_context", "_method")
298
370
 
299
- __slots__ = ("_around", "_closed")
371
+ def __init__(
372
+ self,
373
+ around: contextlib.AbstractAsyncContextManager[None],
374
+ method: str,
375
+ *,
376
+ pin_context: bool,
377
+ ) -> None:
378
+ """Wrap one generator, pinning a context for it when its teardown will run elsewhere.
300
379
 
301
- def __init__(self, around: contextlib.AbstractAsyncContextManager[None]) -> None:
380
+ Args:
381
+ around: The `around_call` generator, already wrapped as an async context manager.
382
+ method: Full method name of the call, for the log record a failing teardown produces.
383
+ pin_context: Whether to give the generator a context of its own, which every kind but
384
+ unary-unary needs.
385
+ """
302
386
  self._around = around
387
+ self._method = method
388
+ self._context = contextvars.copy_context() if pin_context else None
303
389
  self._closed = False
304
390
 
305
391
  @property
306
392
  def closed(self) -> bool:
307
- """Whether the context has already been closed."""
393
+ """Whether the generator has already been closed."""
308
394
  return self._closed
309
395
 
396
+ @property
397
+ def context(self) -> contextvars.Context | None:
398
+ """The pinned context, or None when the generator runs wherever it is stepped from."""
399
+ return self._context
400
+
401
+ def in_context[T](self, coro: Coroutine[Any, Any, T]) -> asyncio.Task[T]:
402
+ """Step one coroutine in the pinned context, as a task of its own."""
403
+ return asyncio.get_running_loop().create_task(coro, context=self._context)
404
+
405
+ async def enter(self) -> None:
406
+ """Run the setup — the code before the ``yield``. Raising here refuses the call."""
407
+ await self._around.__aenter__()
408
+
310
409
  async def close(self, error: BaseException | None) -> None:
311
- """Close the context with the call's outcome, once."""
410
+ """Close the generator with the call's outcome, once, in the context the setup ran in.
411
+
412
+ The iterator path and the call's done callback both race to report the outcome; whichever
413
+ arrives second must find the work already done rather than throw a second exception into a
414
+ generator that has already stopped.
415
+
416
+ Args:
417
+ error: How the call ended, or None if it succeeded.
418
+ """
312
419
  if self._closed:
313
420
  return
314
421
  self._closed = True
315
- if error is None:
316
- await self._around.__aexit__(None, None, None)
317
- else:
318
- await self._around.__aexit__(type(error), error, error.__traceback__)
422
+ # Already the right context on the unary-unary path, which pins none, and on every caller
423
+ # that the scope itself started — the teardown then costs no second task.
424
+ if self._context is None or self._context is _running_context():
425
+ await self._teardown(error)
426
+ return
427
+ await self.in_context(self._teardown(error))
428
+
429
+ async def _teardown(self, error: BaseException | None) -> None:
430
+ """Run the code after the ``yield`` and let nothing it raises reach the call."""
431
+ try:
432
+ if error is None:
433
+ await self._around.__aexit__(None, None, None)
434
+ else:
435
+ await self._around.__aexit__(type(error), error, error.__traceback__)
436
+ except Exception:
437
+ # Dropped deliberately, and logged so that it is not lost: see the class docstring.
438
+ # Cancellation is not caught — a task torn down around the teardown is not the teardown
439
+ # failing, and swallowing it would strand a cancel that the caller asked for.
440
+ logger.exception("around_call teardown failed for %s", self._method)
319
441
 
320
442
 
321
- async def _finalize_unary_outcome(call: ClientCall, deferred: Any, finalizer: _AroundFinalizer) -> None:
322
- """Observe a deferred unary outcome and close the around context with it."""
443
+ async def _finalize_unary_outcome(call: ClientCall, deferred: Any, scope: _AroundScope) -> None:
444
+ """Observe a deferred unary outcome and close the around scope with it."""
323
445
  try:
324
446
  response = await deferred if hasattr(deferred, "__await__") else deferred
325
447
  except BaseException as error:
326
- await finalizer.close(error)
448
+ await scope.close(error)
327
449
  else:
328
450
  call._response = response
329
- await finalizer.close(None)
451
+ await scope.close(None)
330
452
 
331
453
 
332
- def _finalize_when_done(underlying: Any, finalizer: _AroundFinalizer) -> None:
333
- """Arrange for the around context to close when the call finishes, however it finishes."""
454
+ def _finalize_when_done(underlying: Any, scope: _AroundScope) -> None:
455
+ """Arrange for the around scope to close when the call finishes, however it finishes."""
334
456
  add_done_callback = getattr(underlying, "add_done_callback", None)
335
457
  if add_done_callback is None:
336
458
  return
337
459
 
338
460
  def _on_done(done_call: Any) -> None:
339
- if not finalizer.closed:
340
- _spawn_background(_finalize_stream_outcome(done_call, finalizer))
461
+ if not scope.closed:
462
+ _spawn_background(_finalize_stream_outcome(done_call, scope), context=scope.context)
341
463
 
342
464
  add_done_callback(_on_done)
343
465
 
344
466
 
345
- async def _finalize_stream_outcome(done_call: Any, finalizer: _AroundFinalizer) -> None:
346
- """Close the around context with the status of an already finished streaming call."""
347
- if finalizer.closed:
467
+ async def _finalize_stream_outcome(done_call: Any, scope: _AroundScope) -> None:
468
+ """Close the around scope with the status of an already finished streaming call."""
469
+ if scope.closed:
348
470
  return
349
471
  try:
350
472
  code = await done_call.code()
351
473
  except BaseException as error:
352
- await finalizer.close(error)
474
+ await scope.close(error)
353
475
  return
354
476
 
355
477
  if code == grpc.StatusCode.OK:
356
- await finalizer.close(None)
478
+ await scope.close(None)
357
479
  elif done_call.cancelled():
358
- await finalizer.close(asyncio.CancelledError())
480
+ await scope.close(asyncio.CancelledError())
359
481
  else:
360
482
  failure = grpc.aio.AioRpcError(
361
483
  code,
@@ -363,14 +485,14 @@ async def _finalize_stream_outcome(done_call: Any, finalizer: _AroundFinalizer)
363
485
  await done_call.trailing_metadata(),
364
486
  await done_call.details(),
365
487
  )
366
- await finalizer.close(failure)
488
+ await scope.close(failure)
367
489
 
368
490
 
369
491
  async def _closing_stream(
370
- finalizer: _AroundFinalizer,
492
+ scope: _AroundScope,
371
493
  stream: AsyncIterator[Any],
372
494
  ) -> AsyncIterator[Any]:
373
- """Yield a whole response stream, then close the around context with however it ended."""
495
+ """Yield a whole response stream, then close the around scope with however it ended."""
374
496
  try:
375
497
  async for item in stream:
376
498
  yield item
@@ -379,14 +501,14 @@ async def _closing_stream(
379
501
  # view the call did not finish; closing with a cancellation says exactly that, while
380
502
  # throwing GeneratorExit into the around generators would only produce "generator didn't
381
503
  # stop after athrow" noise. The done callback of the underlying call, when there is one,
382
- # may report the real outcome first — the finalizer keeps whichever arrived first.
383
- await finalizer.close(asyncio.CancelledError())
504
+ # may report the real outcome first — the scope keeps whichever arrived first.
505
+ await scope.close(asyncio.CancelledError())
384
506
  raise
385
507
  except BaseException as error:
386
- await finalizer.close(error)
508
+ await scope.close(error)
387
509
  raise
388
510
 
389
- await finalizer.close(None)
511
+ await scope.close(None)
390
512
 
391
513
 
392
514
  class _Adapter:
@@ -113,11 +113,15 @@ class AsyncClientTracingInterceptor(AsyncClientInterceptor):
113
113
 
114
114
  Note:
115
115
  This layer implements `base.AsyncClientInterceptor.intercept` rather than the simpler
116
- ``around_call`` seam, because the span must be made current only while the RPC is being
117
- created. ``around_call`` resumes when the *whole* call is over, and for a streaming response
118
- that happens in whichever task drains the stream, while gRPC runs the interceptor chain in a
119
- task of its own: detaching the OpenTelemetry context there fails ("Failed to detach
120
- context") and leaves the client span current in the task that started the call.
116
+ ``around_call`` seam. It once had no choice: the teardown of an ``around_call`` resumed in
117
+ whichever task drained the stream, and detaching the OpenTelemetry context there failed
118
+ ("Failed to detach context"), leaving the client span current in the task that started the
119
+ call. `base._AroundScope` steps both sides of the ``yield`` in one context now, so an
120
+ ``attach`` / ``detach`` pair across it is supported and that reason is gone. What keeps
121
+ this layer on `intercept` is cost: with ``opentelemetry-api`` installed and no SDK
122
+ configured — the default state of the ``tracing`` extra — every span is non-recording and
123
+ this interceptor does nothing at all, while an around scope would be opened for every call
124
+ regardless, and on a streaming call that is a task per RPC.
121
125
 
122
126
  Note:
123
127
  Without the ``tracing`` extra (``opentelemetry-api``) the interceptor is a documented
@@ -0,0 +1,418 @@
1
+ """The settings shape the factory reads, as pydantic models, written down once.
2
+
3
+ `factory.GrpcClientFactory` reads a settings object structurally — `protocols.GrpcClientSettingsProtocol`
4
+ and the optional blocks around it — so every service used to write its own models for that shape and
5
+ its own mapping onto the kit's dataclasses, and every copy drifted in its own way. These models are
6
+ that shape with the kit's own defaults and bounds; each one that mirrors a dataclass knows how to
7
+ become it (``to_config()``), and the kit's own tests pin the two field sets to each other.
8
+
9
+ Every class here is a plain :class:`pydantic.BaseModel`, deliberately. A section that were a
10
+ ``BaseSettings`` would read the environment on its own, with no prefix, and a bare ``TARGET`` or
11
+ ``TIMEOUT`` in a pod would land in it. Nest :class:`BaseGrpcClientSettings` under the service's own
12
+ settings instead, once per upstream, and let that class own the environment::
13
+
14
+ from pydantic import Field
15
+ from pydantic_settings import BaseSettings, SettingsConfigDict
16
+
17
+ from grpc_client_kit import GrpcClientFactory
18
+ from grpc_client_kit.settings import BaseGrpcClientSettings
19
+
20
+
21
+ class Settings(BaseSettings):
22
+ model_config = SettingsConfigDict(env_nested_delimiter="__")
23
+
24
+ users_grpc: BaseGrpcClientSettings = Field(default_factory=BaseGrpcClientSettings)
25
+
26
+
27
+ # USERS_GRPC__TARGET=users.internal:50051 USERS_GRPC__RETRY__MAX_ATTEMPTS=5 python main.py
28
+ settings = Settings()
29
+ factory = GrpcClientFactory(settings=settings.users_grpc)
30
+
31
+ Runtime objects are not settings and have no field here: channel credentials are handed to
32
+ :meth:`BaseGrpcClientSettings.to_config`, metrics registries and retry callbacks to the factory or to
33
+ the chain built from the sections. Unknown fields are refused, so a misspelled variable fails at load
34
+ instead of quietly yielding a default.
35
+
36
+ This module needs the ``settings`` extra (``grpc-client-kit[settings]``), which pulls in pydantic;
37
+ importing it without raises an ``ImportError`` naming the extra.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import logging
43
+ from typing import Any
44
+
45
+ import grpc
46
+
47
+ from .balancers import LoadBalancerConfig, LoadBalancingStrategy
48
+ from .config import ConnectivityConfig, GrpcClientConfig
49
+ from .interceptors import (
50
+ CircuitBreakerConfig,
51
+ DeadlineBudgetConfig,
52
+ RetryConfig,
53
+ TimeoutConfig,
54
+ WaitForReadyConfig,
55
+ )
56
+
57
+ try:
58
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
59
+ except ImportError as exc:
60
+ raise ImportError("Install grpc-client-kit[settings] (pydantic) to use the settings models") from exc
61
+
62
+ # The names an operator writes for `grpc.Compression`. "none" is gRPC's explicit no-compression
63
+ # setting, not the absence of one — that is ``None``, which leaves the gRPC default alone.
64
+ _COMPRESSION_BY_NAME: dict[str, grpc.Compression] = {
65
+ "none": grpc.Compression.NoCompression,
66
+ "deflate": grpc.Compression.Deflate,
67
+ "gzip": grpc.Compression.Gzip,
68
+ }
69
+
70
+
71
+ def _status_code_by_name(value: object) -> object:
72
+ """Resolve a status code written by name (``UNAVAILABLE``); anything else is left to pydantic."""
73
+ if not isinstance(value, str):
74
+ return value
75
+ try:
76
+ return grpc.StatusCode[value.upper()]
77
+ except KeyError:
78
+ names = ", ".join(code.name for code in grpc.StatusCode)
79
+ raise ValueError(f"unknown gRPC status code {value!r}; expected one of {names}") from None
80
+
81
+
82
+ class BaseConnectivitySettings(BaseModel):
83
+ """``connectivity``: keepalive and reconnect backoff in seconds, `config.ConnectivityConfig` by environment.
84
+
85
+ Opt-in like the dataclass: an absent block leaves every channel argument at gRPC's own default,
86
+ and ``None`` in a field does the same for that argument alone.
87
+ """
88
+
89
+ model_config = ConfigDict(extra="forbid")
90
+
91
+ keepalive_time: float | None = Field(
92
+ default=30.0, gt=0, description="Seconds of inactivity before a keepalive ping (None: never ping)"
93
+ )
94
+ keepalive_timeout: float | None = Field(default=10.0, gt=0, description="Seconds to wait for the ping's answer")
95
+ permit_without_calls: bool = Field(default=False, description="Keep pinging while no call is in flight")
96
+ max_pings_without_data: int | None = Field(
97
+ default=2, ge=0, description="Pings allowed on a connection carrying no data (0: no limit)"
98
+ )
99
+ initial_reconnect_backoff: float | None = Field(
100
+ default=1.0, gt=0, description="Seconds before the first reconnect attempt"
101
+ )
102
+ min_reconnect_backoff: float | None = Field(
103
+ default=None, gt=0, description="Lower bound between reconnect attempts (None: gRPC's default)"
104
+ )
105
+ max_reconnect_backoff: float | None = Field(
106
+ default=30.0, gt=0, description="Upper bound between reconnect attempts"
107
+ )
108
+
109
+ def to_config(self) -> ConnectivityConfig:
110
+ """Build the `config.ConnectivityConfig` these fields describe.
111
+
112
+ Raises:
113
+ ValueError: If the reconnect bounds contradict each other; the dataclass keeps that check.
114
+ """
115
+ return ConnectivityConfig(**self.model_dump())
116
+
117
+
118
+ class BaseChannelPoolSettings(BaseModel):
119
+ """``pool``: what `channel.ChannelPool` is sized with, read by the factory."""
120
+
121
+ model_config = ConfigDict(extra="forbid")
122
+
123
+ max_channels_per_target: int = Field(default=1, ge=1, description="Channels per channel identity")
124
+ idle_timeout: float = Field(
125
+ default=300.0, description="Seconds without active RPCs before gRPC parks a connection (0: never)"
126
+ )
127
+
128
+
129
+ class BaseTimeoutSettings(BaseModel):
130
+ """``timeout``: the budget of a whole call, retries included; `interceptors.TimeoutConfig` by environment."""
131
+
132
+ model_config = ConfigDict(extra="forbid")
133
+
134
+ default: float = Field(
135
+ default=10.0, ge=0, description="Budget in seconds for methods not listed in per_method (0: no deadline)"
136
+ )
137
+ per_method: dict[str, float | None] = Field(
138
+ default_factory=dict,
139
+ description='Budgets by full method name, as JSON: {"/pkg.Service/Method": 60} (null: no deadline)',
140
+ )
141
+
142
+ def to_config(self) -> TimeoutConfig:
143
+ """Build the `interceptors.TimeoutConfig` these fields describe."""
144
+ return TimeoutConfig(**self.model_dump())
145
+
146
+
147
+ class BaseRetrySettings(BaseModel):
148
+ """``retry``: the retry policy, `interceptors.RetryConfig` by environment.
149
+
150
+ Status codes are written by name (``UNAVAILABLE``). ``on_retry`` and ``metrics`` are runtime
151
+ objects and have no field here; the factory injects the registry, a hand-built chain sets both on
152
+ the config `to_config` returns.
153
+ """
154
+
155
+ model_config = ConfigDict(extra="forbid")
156
+
157
+ max_attempts: int = Field(default=3, ge=1, description="Total attempts, the first one included")
158
+ initial_backoff: float = Field(default=0.1, ge=0, description="Seconds before the first retry")
159
+ max_backoff: float = Field(default=10.0, ge=0, description="Upper bound for the wait between retries")
160
+ backoff_multiplier: float = Field(default=2.0, ge=1, description="Growth factor of the backoff per attempt")
161
+ jitter: float = Field(default=0.1, ge=0, le=1, description="Random variation applied to the backoff")
162
+ retryable_codes: set[grpc.StatusCode] | None = Field(
163
+ default=None, description="Status codes that trigger a retry, by name (None: the kit's default set)"
164
+ )
165
+ retry_streaming: bool = Field(default=False, description="Allow retrying unary-stream calls at all")
166
+ idempotent_methods: set[str] | None = Field(
167
+ default=None, description="Full method names that may be retried; a whitelist once given"
168
+ )
169
+
170
+ @field_validator("retryable_codes", mode="before")
171
+ @classmethod
172
+ def _codes_by_name(cls, value: object) -> object:
173
+ if isinstance(value, (list, tuple, set, frozenset)):
174
+ return {_status_code_by_name(item) for item in value}
175
+ return value
176
+
177
+ def to_config(self) -> RetryConfig:
178
+ """Build the `interceptors.RetryConfig` these fields describe, with no callback and no registry."""
179
+ return RetryConfig(**self.model_dump())
180
+
181
+
182
+ class BaseCircuitBreakerSettings(BaseModel):
183
+ """``circuit_breaker``: per-method, per-target breaking; `interceptors.CircuitBreakerConfig` by environment.
184
+
185
+ ``metrics`` is a runtime object and has no field here; the factory injects the registry.
186
+ """
187
+
188
+ model_config = ConfigDict(extra="forbid")
189
+
190
+ fail_threshold: int = Field(default=5, ge=1, description="Consecutive failed attempts that open a circuit")
191
+ recovery_timeout: float = Field(default=60.0, ge=0, description="Seconds an open circuit waits before a trial")
192
+ half_open_max_calls: int = Field(default=1, ge=1, description="Trial calls admitted while half-open")
193
+ max_methods: int = Field(default=1000, ge=1, description="Methods tracked per breaker before the LRU evicts")
194
+
195
+ def to_config(self) -> CircuitBreakerConfig:
196
+ """Build the `interceptors.CircuitBreakerConfig` these fields describe, with no registry."""
197
+ return CircuitBreakerConfig(**self.model_dump())
198
+
199
+
200
+ class BaseWaitForReadySettings(BaseModel):
201
+ """``wait_for_ready``: wait for a connection instead of failing fast; `interceptors.WaitForReadyConfig`."""
202
+
203
+ model_config = ConfigDict(extra="forbid")
204
+
205
+ default: bool | None = Field(
206
+ default=True, description="Value for methods not listed in per_method (None: untouched)"
207
+ )
208
+ per_method: dict[str, bool | None] = Field(
209
+ default_factory=dict, description='Values by full method name, as JSON: {"/pkg.Service/Method": false}'
210
+ )
211
+ require_deadline: bool = Field(default=True, description="Wait only on calls that carry a deadline")
212
+
213
+ def to_config(self) -> WaitForReadyConfig:
214
+ """Build the `interceptors.WaitForReadyConfig` these fields describe."""
215
+ return WaitForReadyConfig(**self.model_dump())
216
+
217
+
218
+ class BaseDeadlineBudgetSettings(BaseModel):
219
+ """``deadline_budget``: request budget propagation; `interceptors.DeadlineBudgetConfig` by environment.
220
+
221
+ Needs the ``deadline`` extra to take effect; without it the layer is skipped with a warning.
222
+ """
223
+
224
+ model_config = ConfigDict(extra="forbid")
225
+
226
+ reserve_for_next: float = Field(
227
+ default=0.0, ge=0, description="Seconds every call keeps back for the work that follows it"
228
+ )
229
+
230
+ def to_config(self) -> DeadlineBudgetConfig:
231
+ """Build the `interceptors.DeadlineBudgetConfig` these fields describe."""
232
+ return DeadlineBudgetConfig(**self.model_dump())
233
+
234
+
235
+ class BaseLoadBalancerSettings(BaseModel):
236
+ """``balancer``: how a target is picked from ``targets``; `balancers.LoadBalancerConfig` by environment."""
237
+
238
+ model_config = ConfigDict(extra="forbid")
239
+
240
+ strategy: LoadBalancingStrategy = Field(
241
+ default=LoadBalancingStrategy.ROUND_ROBIN, description="round_robin, random or weighted"
242
+ )
243
+ weights: dict[str, float] | None = Field(
244
+ default=None, description='Weights by target for the weighted strategy, as JSON: {"host:50051": 2.0}'
245
+ )
246
+
247
+ def to_config(self) -> LoadBalancerConfig:
248
+ """Build the `balancers.LoadBalancerConfig` these fields describe."""
249
+ return LoadBalancerConfig(**self.model_dump())
250
+
251
+
252
+ class BaseHealthCheckerSettings(BaseModel):
253
+ """``health_checker``: active ``grpc.health.v1`` probing of ``targets``, read by the factory.
254
+
255
+ Needs the ``health`` extra: the factory raises ``ImportError`` naming it when this block is set.
256
+ """
257
+
258
+ model_config = ConfigDict(extra="forbid")
259
+
260
+ check_interval: float = Field(default=30.0, gt=0, description="Seconds between probes of a healthy target")
261
+ timeout: float = Field(default=5.0, gt=0, description="Seconds one probe may take")
262
+ service: str = Field(default="", description="Service name probed; empty asks about the server as a whole")
263
+
264
+
265
+ class BaseGrpcClientSettings(BaseModel):
266
+ """One upstream's client settings: what the factory reads, and `config.GrpcClientConfig` by `to_config`.
267
+
268
+ Satisfies `protocols.GrpcClientSettingsProtocol` and carries every optional block the factory
269
+ reads, so an instance is handed to `factory.GrpcClientFactory` as it is. The observability flags
270
+ are flat because that is how the protocol spells them; the rest of
271
+ `interceptors.ObservabilityConfig` sits next to them under the dataclass's own names, minus
272
+ ``service_name`` (the factory names each client after its stub) and the registry.
273
+
274
+ Two blocks are present by default: ``pool``, at the pool's own defaults, and ``timeout``, because
275
+ a client without a deadline is the one default this kit will not ship — ``TIMEOUT__DEFAULT=0``
276
+ switches it off. Everything that adds a layer or needs an extra — retries, the breaker,
277
+ wait-for-ready, budget propagation, balancing, health checks, connectivity tuning — is ``None``
278
+ until configured; with pydantic-settings a nested block is created the moment one of its
279
+ variables is set. Subclass it to change a default or add fields of your own::
280
+
281
+ class UsersGrpcSettings(BaseGrpcClientSettings):
282
+ retry: BaseRetrySettings | None = Field(default_factory=BaseRetrySettings)
283
+ """
284
+
285
+ model_config = ConfigDict(extra="forbid")
286
+
287
+ # Channel
288
+ target: str | None = Field(
289
+ default=None, description="Single target, host:port or a gRPC resolver URI; exclusive with targets"
290
+ )
291
+ targets: list[str] | None = Field(default=None, description="Targets to balance across; exclusive with target")
292
+ insecure: bool = Field(
293
+ default=False,
294
+ description="Plaintext channel (False: TLS, from the system trust store or to_config's credentials)",
295
+ )
296
+ options: list[tuple[str, Any]] | None = Field(
297
+ default=None, description='Raw channel arguments, as JSON pairs: [["grpc.max_receive_message_length", 8388608]]'
298
+ )
299
+ compression: grpc.Compression | None = Field(
300
+ default=None, description="Channel compression by name: none, deflate or gzip (None: the gRPC default)"
301
+ )
302
+ connectivity: BaseConnectivitySettings | None = Field(
303
+ default=None, description="Keepalive and reconnect tuning (None: gRPC's defaults)"
304
+ )
305
+
306
+ # Observability, flat: this is how GrpcClientSettingsProtocol spells the flags
307
+ tracing_enabled: bool = Field(default=False, description="Add the tracing layer (needs the tracing extra)")
308
+ metrics_enabled: bool = Field(default=False, description="Add the metrics layer (needs a registry)")
309
+ logging_enabled: bool = Field(default=True, description="Add the structured logging layer")
310
+ sensitive_headers: set[str] | None = Field(
311
+ default=None, description="Header names redacted in logged metadata (None: the kit's default set)"
312
+ )
313
+ sensitive_methods: set[str] | None = Field(
314
+ default=None, description="Full method names whose payloads and errors are kept out of the logs"
315
+ )
316
+ sensitive_patterns: list[str] | None = Field(
317
+ default=None, description="Regex patterns marking methods as sensitive"
318
+ )
319
+ log_request_payload: bool = Field(default=False, description="Log request payloads, truncated")
320
+ log_response_payload: bool = Field(default=False, description="Log response payloads, truncated")
321
+ enable_method_label: bool = Field(
322
+ default=True, description="Label metrics by method (off for services with thousands of methods)"
323
+ )
324
+ success_log_level: int = Field(
325
+ default=logging.INFO, description="Level of the record a successful call emits, by name or number"
326
+ )
327
+
328
+ # The blocks the factory maps onto the pool, the chain, the balancer and the health checker
329
+ pool: BaseChannelPoolSettings | None = Field(
330
+ default_factory=BaseChannelPoolSettings, description="Channel pool sizing"
331
+ )
332
+ timeout: BaseTimeoutSettings | None = Field(
333
+ default_factory=BaseTimeoutSettings, description="Call budgets (None: no timeout layer, so no deadline at all)"
334
+ )
335
+ retry: BaseRetrySettings | None = Field(default=None, description="Retry policy (None: no retry layer)")
336
+ circuit_breaker: BaseCircuitBreakerSettings | None = Field(
337
+ default=None, description="Circuit breaker (None: no breaker layer)"
338
+ )
339
+ wait_for_ready: BaseWaitForReadySettings | None = Field(
340
+ default=None, description="Wait for a connection instead of failing fast (None: gRPC's fail-fast)"
341
+ )
342
+ deadline_budget: BaseDeadlineBudgetSettings | None = Field(
343
+ default=None, description="Request budget propagation (None: none; needs the deadline extra)"
344
+ )
345
+ balancer: BaseLoadBalancerSettings | None = Field(
346
+ default=None, description="Strategy over targets (None: round-robin)"
347
+ )
348
+ health_checker: BaseHealthCheckerSettings | None = Field(
349
+ default=None, description="Active probing of targets (None: none; needs the health extra)"
350
+ )
351
+
352
+ @field_validator("compression", mode="before")
353
+ @classmethod
354
+ def _compression_by_name(cls, value: object) -> object:
355
+ if not isinstance(value, str):
356
+ return value
357
+ try:
358
+ return _COMPRESSION_BY_NAME[value.lower()]
359
+ except KeyError:
360
+ names = ", ".join(_COMPRESSION_BY_NAME)
361
+ raise ValueError(f"unknown compression {value!r}; expected one of {names}") from None
362
+
363
+ @field_validator("success_log_level", mode="before")
364
+ @classmethod
365
+ def _level_by_name(cls, value: object) -> object:
366
+ # An environment hands over strings, so a numeric one is left to pydantic's int coercion.
367
+ if not isinstance(value, str) or value.isdecimal():
368
+ return value
369
+ try:
370
+ return logging.getLevelNamesMapping()[value.upper()]
371
+ except KeyError:
372
+ raise ValueError(f"unknown log level {value!r}") from None
373
+
374
+ @model_validator(mode="after")
375
+ def _one_way_to_name_the_backend(self) -> BaseGrpcClientSettings:
376
+ if self.target is not None and self.targets:
377
+ raise ValueError("target and targets are mutually exclusive: one address, or a list to balance across")
378
+ return self
379
+
380
+ def to_config(self, *, credentials: grpc.ChannelCredentials | None = None) -> GrpcClientConfig:
381
+ """Build the `config.GrpcClientConfig` these fields describe.
382
+
383
+ The chain is built from the sections (``settings.retry.to_config()`` and so on, into
384
+ `interceptors.build_interceptors`); the factory does both from the same instance.
385
+
386
+ Args:
387
+ credentials: TLS credentials for the channel. A credentials object is built in code, not
388
+ read from an environment, so it is handed in here — and ``insecure=True`` together with
389
+ credentials is refused by the dataclass, exactly as it always was.
390
+
391
+ Returns:
392
+ The channel configuration.
393
+
394
+ Raises:
395
+ ValueError: If the configuration contradicts itself; the dataclasses keep those checks.
396
+ """
397
+ return GrpcClientConfig(
398
+ target=self.target,
399
+ insecure=self.insecure,
400
+ credentials=credentials,
401
+ options=self.options,
402
+ compression=self.compression,
403
+ connectivity=self.connectivity.to_config() if self.connectivity is not None else None,
404
+ )
405
+
406
+
407
+ __all__ = [
408
+ "BaseChannelPoolSettings",
409
+ "BaseCircuitBreakerSettings",
410
+ "BaseConnectivitySettings",
411
+ "BaseDeadlineBudgetSettings",
412
+ "BaseGrpcClientSettings",
413
+ "BaseHealthCheckerSettings",
414
+ "BaseLoadBalancerSettings",
415
+ "BaseRetrySettings",
416
+ "BaseTimeoutSettings",
417
+ "BaseWaitForReadySettings",
418
+ ]
@@ -41,11 +41,14 @@ metrics = [
41
41
  deadline = [
42
42
  "deadline-budget>=0.1.0,<1.0.0",
43
43
  ]
44
+ settings = [
45
+ "pydantic>=2.0.0,<3.0",
46
+ ]
44
47
  observability = [
45
48
  "grpc-client-kit[metrics,tracing]",
46
49
  ]
47
50
  all = [
48
- "grpc-client-kit[deadline,health,metrics,tracing]",
51
+ "grpc-client-kit[deadline,health,metrics,settings,tracing]",
49
52
  ]
50
53
 
51
54
  [dependency-groups]
@@ -55,6 +58,7 @@ test = [
55
58
  "pytest-cov>=7.0.0",
56
59
  "pytest-lazy-fixtures>=1.1.1",
57
60
  "freezegun>=1.5.5",
61
+ "pydantic-settings>=2.0.0,<3.0",
58
62
  ]
59
63
  docs = [
60
64
  "zensical>=0.0.37",
@@ -1 +0,0 @@
1
- __version__ = "0.1.1" # x-release-please-version
File without changes