cloudru-qa-grpc-client 1.0.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 (67) hide show
  1. cloudru_qa_grpc_client-1.0.0/.agents/skills/grpc-client-usage/SKILL.md +344 -0
  2. cloudru_qa_grpc_client-1.0.0/.agents/skills/grpc-client-usage/references/options.md +260 -0
  3. cloudru_qa_grpc_client-1.0.0/.agents/skills/grpc-client-usage/references/pitfalls.md +105 -0
  4. cloudru_qa_grpc_client-1.0.0/.agents/skills/grpc-client-usage/references/responses.md +153 -0
  5. cloudru_qa_grpc_client-1.0.0/.gitignore +26 -0
  6. cloudru_qa_grpc_client-1.0.0/LICENSE +202 -0
  7. cloudru_qa_grpc_client-1.0.0/PKG-INFO +630 -0
  8. cloudru_qa_grpc_client-1.0.0/README.md +602 -0
  9. cloudru_qa_grpc_client-1.0.0/gen-test-api.sh +60 -0
  10. cloudru_qa_grpc_client-1.0.0/grpc_client/__init__.py +75 -0
  11. cloudru_qa_grpc_client-1.0.0/grpc_client/async_client.py +271 -0
  12. cloudru_qa_grpc_client-1.0.0/grpc_client/base_client.py +175 -0
  13. cloudru_qa_grpc_client-1.0.0/grpc_client/call_options.py +71 -0
  14. cloudru_qa_grpc_client-1.0.0/grpc_client/client.py +203 -0
  15. cloudru_qa_grpc_client-1.0.0/grpc_client/config.py +74 -0
  16. cloudru_qa_grpc_client-1.0.0/grpc_client/exceptions.py +59 -0
  17. cloudru_qa_grpc_client-1.0.0/grpc_client/interceptors/__init__.py +23 -0
  18. cloudru_qa_grpc_client-1.0.0/grpc_client/interceptors/auth.py +194 -0
  19. cloudru_qa_grpc_client-1.0.0/grpc_client/interceptors/base.py +391 -0
  20. cloudru_qa_grpc_client-1.0.0/grpc_client/interceptors/logging.py +765 -0
  21. cloudru_qa_grpc_client-1.0.0/grpc_client/interceptors/observability.py +128 -0
  22. cloudru_qa_grpc_client-1.0.0/grpc_client/logsink.py +188 -0
  23. cloudru_qa_grpc_client-1.0.0/grpc_client/metadata.py +110 -0
  24. cloudru_qa_grpc_client-1.0.0/grpc_client/options.py +282 -0
  25. cloudru_qa_grpc_client-1.0.0/grpc_client/py.typed +0 -0
  26. cloudru_qa_grpc_client-1.0.0/grpc_client/response.py +527 -0
  27. cloudru_qa_grpc_client-1.0.0/grpc_client/status.py +105 -0
  28. cloudru_qa_grpc_client-1.0.0/grpc_client/types.py +77 -0
  29. cloudru_qa_grpc_client-1.0.0/grpc_client/utils/__init__.py +0 -0
  30. cloudru_qa_grpc_client-1.0.0/grpc_client/utils/logutils.py +299 -0
  31. cloudru_qa_grpc_client-1.0.0/grpc_client/utils/sanitizer.py +206 -0
  32. cloudru_qa_grpc_client-1.0.0/grpc_client/utils/traceparent.py +36 -0
  33. cloudru_qa_grpc_client-1.0.0/proto/examples/examplepb/a_bit_of_everything.proto +316 -0
  34. cloudru_qa_grpc_client-1.0.0/proto/examples/examplepb/echo_service.proto +40 -0
  35. cloudru_qa_grpc_client-1.0.0/proto/examples/examplepb/flow_combination.proto +168 -0
  36. cloudru_qa_grpc_client-1.0.0/proto/examples/examplepb/stream.proto +29 -0
  37. cloudru_qa_grpc_client-1.0.0/proto/examples/examplepb/wrappers.proto +24 -0
  38. cloudru_qa_grpc_client-1.0.0/proto/examples/sub/message.proto +7 -0
  39. cloudru_qa_grpc_client-1.0.0/proto/examples/sub2/message.proto +7 -0
  40. cloudru_qa_grpc_client-1.0.0/proto/google/api/annotations.proto +31 -0
  41. cloudru_qa_grpc_client-1.0.0/proto/google/api/http.proto +291 -0
  42. cloudru_qa_grpc_client-1.0.0/proto/grpcbin.proto +77 -0
  43. cloudru_qa_grpc_client-1.0.0/proto/options/options.proto +26 -0
  44. cloudru_qa_grpc_client-1.0.0/proto/protoc-gen-swagger/options/annotations.proto +37 -0
  45. cloudru_qa_grpc_client-1.0.0/proto/protoc-gen-swagger/options/openapiv2.proto +338 -0
  46. cloudru_qa_grpc_client-1.0.0/proto/runtime/internal/stream_chunk.proto +15 -0
  47. cloudru_qa_grpc_client-1.0.0/pyproject.toml +87 -0
  48. cloudru_qa_grpc_client-1.0.0/tests/__init__.py +0 -0
  49. cloudru_qa_grpc_client-1.0.0/tests/assertions.py +202 -0
  50. cloudru_qa_grpc_client-1.0.0/tests/clients/__init__.py +10 -0
  51. cloudru_qa_grpc_client-1.0.0/tests/clients/a_bit_of_everything.py +42 -0
  52. cloudru_qa_grpc_client-1.0.0/tests/clients/grpcbin.py +230 -0
  53. cloudru_qa_grpc_client-1.0.0/tests/conftest.py +157 -0
  54. cloudru_qa_grpc_client-1.0.0/tests/docker-compose.yml +6 -0
  55. cloudru_qa_grpc_client-1.0.0/tests/fakes.py +512 -0
  56. cloudru_qa_grpc_client-1.0.0/tests/support.py +12 -0
  57. cloudru_qa_grpc_client-1.0.0/tests/test_async.py +691 -0
  58. cloudru_qa_grpc_client-1.0.0/tests/test_auth.py +234 -0
  59. cloudru_qa_grpc_client-1.0.0/tests/test_client.py +87 -0
  60. cloudru_qa_grpc_client-1.0.0/tests/test_log_sink.py +521 -0
  61. cloudru_qa_grpc_client-1.0.0/tests/test_logging.py +350 -0
  62. cloudru_qa_grpc_client-1.0.0/tests/test_metadata.py +96 -0
  63. cloudru_qa_grpc_client-1.0.0/tests/test_observability.py +93 -0
  64. cloudru_qa_grpc_client-1.0.0/tests/test_requests.py +112 -0
  65. cloudru_qa_grpc_client-1.0.0/tests/test_sanitizer.py +248 -0
  66. cloudru_qa_grpc_client-1.0.0/tests/test_status.py +58 -0
  67. cloudru_qa_grpc_client-1.0.0/tests/test_streaming.py +223 -0
@@ -0,0 +1,344 @@
1
+ ---
2
+ name: grpc-client-usage
3
+ description: Building gRPC clients on the grpc_client library — a grpcio wrapper providing constructor options, four send_*_request methods, response objects that never raise on a non-OK status, lazy streams, parsed google.rpc.Status details, token injection and request logging with secret masking. Use when writing a client for a gRPC service, adding a service method to an existing client, or configuring TLS, timeouts, metadata, authentication, streaming or logging in a project that imports grpc_client. Key names are GRPCClient, AsyncGRPCClient, GRPCResponse, GRPCStreamResponse, CallOptions, Metadata, ClientOption, options.with_*, stub_class, send_unary_unary_request.
4
+ license: Apache-2.0
5
+ metadata:
6
+ library-version: "1.0.0"
7
+ source: packages/grpc-client
8
+ ---
9
+
10
+ # Building clients on grpc_client
11
+
12
+ `grpc_client` is a wrapper around `grpcio`. It adds exactly four things:
13
+
14
+ 1. **Constructor options** (`ClientOption`) — configuration is passed as positional arguments to a factory method instead of channel plumbing spread over the call site.
15
+ 2. **Response objects** — a failed call comes back as a `GRPCResponse` with a status code instead of raising. In tests a rejection is an expected result, not an accident.
16
+ 3. **Lazy streams** — `GRPCStreamResponse` reads messages as they arrive, with `collect()` when the whole batch is wanted.
17
+ 4. **Interceptors out of the box** — auth token injection, `x-request-id`, `traceparent`, and request/response logging with secret masking.
18
+
19
+ Everything else (`grpc.StatusCode`, `grpc.Compression`, `grpc.ChannelCredentials`, `grpc.CallCredentials`, the generated `*_pb2` messages and `*_pb2_grpc` stubs) is plain grpcio and protobuf. Do not invent your own wrappers around what they already provide.
20
+
21
+ ## Hard rules
22
+
23
+ 1. Subclass `GRPCClient` or `AsyncGRPCClient` and implement the `stub_class` property. `BaseGRPCClient` is abstract and owns no channel; never subclass it directly.
24
+ 2. Inside a service method call `self.send_*_request(...)`, **not** `self.stub.Method(...)`. The direct call skips the client's timeout, default metadata and compression — interceptors still run, since they live on the channel.
25
+ 3. Configure through options passed to `from_addr` / `from_channel`. Channel-level settings (`secure`, TLS credentials, channel options, interceptors) are frozen once the channel exists, so assigning them afterwards silently does nothing.
26
+ 4. Do not treat a non-OK call as an exception. Check `response.ok` / `response.status_code`, or call `raise_for_status()` when an exception really is wanted.
27
+ 5. Never determine success with `isinstance(outcome, grpc.RpcError)`. grpcio stream call objects implement `grpc.Call` and `grpc.RpcError` at the same time, even on a healthy call. Use `grpc_client.interceptors.base.outcome_code`.
28
+ 6. Never write a retry loop, backoff or circuit breaker by hand. grpcio has retries built in — configure them through `with_channel_options` and `grpc.service_config`.
29
+ 7. Do not add dependencies to the library itself — it must stay lightweight.
30
+
31
+ Before claiming something cannot be done, check `references/pitfalls.md` — it documents the non-obvious behaviour that trips people up.
32
+
33
+ ## Canonical client (sync)
34
+
35
+ ```python
36
+ # Modules generated from the service .proto files
37
+ from api import example_pb2, example_pb2_grpc
38
+ from grpc_client import CallOptions, GRPCClient, GRPCResponse, MetadataType
39
+
40
+
41
+ class OrdersClient(GRPCClient[example_pb2_grpc.OrdersStub]):
42
+ """Client for the orders service."""
43
+
44
+ @property
45
+ def stub_class(self) -> type[example_pb2_grpc.OrdersStub]:
46
+ return example_pb2_grpc.OrdersStub
47
+
48
+ def get_order(
49
+ self,
50
+ request: example_pb2.GetOrderRequest,
51
+ metadata: MetadataType | None = None,
52
+ options: CallOptions | None = None,
53
+ ) -> GRPCResponse[example_pb2.Order]:
54
+ """Fetch a single order.
55
+
56
+ :param request: request message
57
+ :param metadata: request metadata (the gRPC counterpart of HTTP headers)
58
+ :param options: per-call options overriding the client settings
59
+ :return: response carrying the order
60
+ """
61
+ return self.send_unary_unary_request(request, self.stub.GetOrder, metadata, options)
62
+ ```
63
+
64
+ Three things make this work:
65
+
66
+ 1. The stub class in square brackets types `self.stub`, so `self.stub.GetOrder` is checked.
67
+ 2. The `stub_class` property returns the same class — the base client instantiates it over the channel.
68
+ 3. Each service method wraps one of the four send methods.
69
+
70
+ Assembly:
71
+
72
+ ```python
73
+ import logging
74
+
75
+ from grpc_client import options
76
+
77
+ client = OrdersClient.from_addr(
78
+ "orders.example.com:443",
79
+ options.with_timeout(30),
80
+ options.with_auth(my_auth),
81
+ options.with_logger(logging.getLogger("orders")),
82
+ options.with_autogenerated_request_id(),
83
+ )
84
+ ```
85
+
86
+ Order matters: interceptors run in the order they were added, so `with_logger` goes **after** `with_auth` for the log to show the request that was actually sent.
87
+
88
+ ## Canonical client (async)
89
+
90
+ Same shape; the send methods are awaited and stream responses are async.
91
+
92
+ ```python
93
+ from grpc_client import AsyncGRPCClient, AsyncGRPCStreamResponse, GRPCResponse
94
+
95
+
96
+ class AsyncOrdersClient(AsyncGRPCClient[example_pb2_grpc.OrdersStub]):
97
+ @property
98
+ def stub_class(self) -> type[example_pb2_grpc.OrdersStub]:
99
+ return example_pb2_grpc.OrdersStub
100
+
101
+ async def get_order(self, request: example_pb2.GetOrderRequest) -> GRPCResponse[example_pb2.Order]:
102
+ return await self.send_unary_unary_request(request, self.stub.GetOrder)
103
+ ```
104
+
105
+ Differences from the sync client:
106
+
107
+ - send methods are coroutines, including the ones returning a stream;
108
+ - streams are `AsyncGRPCStreamResponse`: read with `async for`, and status/metadata are coroutines — `await stream.status_code()`. There is no `ok` property;
109
+ - closing is `await client.close()` or `async with`;
110
+ - `from_channel()` **raises `ValueError`** when any interceptor-adding option is passed (`with_auth`, `with_logger`, `with_autogenerated_*`, `with_interceptor`). `grpc.aio` accepts interceptors only at channel creation — build the channel yourself with `grpc.aio.insecure_channel(..., interceptors=[...])`, or use `from_addr()`;
111
+ - `with_auth` needs an `AsyncClientAuth` implementation, where `token()` and `refresh()` are coroutines. Passing a sync one raises `TypeError`.
112
+
113
+ The option set is shared: `options.with_logger(...)` picks the sync or async interceptor by the client it is applied to.
114
+
115
+ ## Choosing how to send
116
+
117
+ | gRPC call type | Method | Returns |
118
+ |---|---|---|
119
+ | unary → unary | `send_unary_unary_request(msg, method, ...)` | `GRPCResponse[T]` |
120
+ | stream → unary (client streaming) | `send_stream_unary_request(iterator, method, ...)` | `GRPCResponse[T]` |
121
+ | unary → stream (server streaming) | `send_unary_stream_request(msg, method, ...)` | `GRPCStreamResponse[T]` |
122
+ | stream → stream (bidirectional) | `send_stream_stream_request(iterator, method, ...)` | `GRPCStreamResponse[T]` |
123
+
124
+ All four take `(payload, method, metadata=None, options=None)`. Request streams are plain `Iterator[Message]` (`iter([msg1, msg2])` is enough); the async client also accepts an `AsyncIterator`.
125
+
126
+ ## Releasing the channel
127
+
128
+ A client built with `from_addr` owns its channel and must close it. Prefer the context manager:
129
+
130
+ ```python
131
+ with OrdersClient.from_addr("localhost:9000", options.with_insecure_channel()) as client:
132
+ client.get_order(request)
133
+
134
+ async with AsyncOrdersClient.from_addr("localhost:9000", options.with_insecure_channel()) as client:
135
+ await client.get_order(request)
136
+ ```
137
+
138
+ In pytest:
139
+
140
+ ```python
141
+ @pytest.fixture
142
+ def orders_client() -> Iterator[OrdersClient]:
143
+ with OrdersClient.from_addr("localhost:9000", options.with_insecure_channel()) as client:
144
+ yield client
145
+ ```
146
+
147
+ A channel passed to `from_channel` is **not** closed by the client — its lifetime belongs to the caller.
148
+
149
+ ## Options
150
+
151
+ The full catalog with exact signatures is in `references/options.md`. Summary:
152
+
153
+ | Option | Purpose |
154
+ |---|---|
155
+ | `with_timeout(timeout)` | default per-call deadline; `None` disables it |
156
+ | `with_insecure_channel()` | plaintext channel (the channel is encrypted by default) |
157
+ | `with_ssl_credentials(credentials)` | custom TLS channel credentials |
158
+ | `with_root_certificates(pem_bytes)` | root certificate for server verification |
159
+ | `with_channel_options(options)` | low-level grpcio channel options, including `grpc.service_config` |
160
+ | `with_compression(compression)` | message compression |
161
+ | `with_default_metadata(metadata)` | metadata attached to every request |
162
+ | `with_auth(auth, retry_on_unauthenticated=True)` | access-token injection |
163
+ | `with_logger(logger, ...)` | request and response logging as text |
164
+ | `with_log_sink(sink, ...)` | the same logging through your own record receiver |
165
+ | `with_autogenerated_request_id(key=None)` | `x-request-id` |
166
+ | `with_autogenerated_traceparent(key=None)` | `traceparent` (W3C Trace Context) |
167
+ | `with_interceptor(*interceptors)` | your own grpcio interceptors |
168
+
169
+ ### Writing your own option
170
+
171
+ `ClientOption` is `Callable[[BaseGRPCClient[Any]], None]`. Options run inside `__init__` **before** the channel is created, so they may set the client fields but must not touch `client.channel` or `client.stub`.
172
+
173
+ ```python
174
+ from typing import Any
175
+
176
+ from grpc_client import ClientOption
177
+ from grpc_client.base_client import BaseGRPCClient
178
+
179
+
180
+ def with_tenant(tenant: str) -> ClientOption:
181
+ def option(client: BaseGRPCClient[Any]) -> None:
182
+ client.default_metadata = (*client.default_metadata, ("x-tenant", tenant))
183
+
184
+ return option
185
+ ```
186
+
187
+ Prefer composing from the built-ins (`with_default_metadata`, `with_interceptor`) over reaching into private fields.
188
+
189
+ ## Working with the response
190
+
191
+ ```python
192
+ response = client.get_order(request)
193
+
194
+ response.ok # bool — status is OK
195
+ response.status_code # grpc.StatusCode
196
+ response.details # str — server-supplied explanation, usually only on failure
197
+ response.message() # the response message; raises MessageUndefinedError when there is none
198
+ response.initial_metadata # Metadata received before the body
199
+ response.trailing_metadata # Metadata received after the body (`metadata` is an alias)
200
+ response.error # the original grpc.RpcError, when the call failed
201
+ response.error_status # ErrorStatus parsed from google.rpc.Status, when the service sent one
202
+ response.raise_for_status() # raises ResponseStatusError unless the status is OK; returns self
203
+ ```
204
+
205
+ A failure is an ordinary result:
206
+
207
+ ```python
208
+ response = client.get_order(request)
209
+
210
+ assert response.status_code == grpc.StatusCode.NOT_FOUND
211
+ assert response.details == "order not found"
212
+ ```
213
+
214
+ `with_parser()` attaches a conversion for the response message and yields a `GRPCResponseWithParser`, whose `parse()` runs it — useful for mapping onto a pydantic model or picking one field out.
215
+
216
+ Details on streams, `ErrorStatus`, metadata and the exception hierarchy are in `references/responses.md`.
217
+
218
+ ## Per-call options
219
+
220
+ Client settings apply to every request; a single call is tuned with `CallOptions`:
221
+
222
+ ```python
223
+ from grpc_client import CallOptions
224
+
225
+ response = client.get_order(
226
+ request,
227
+ options=CallOptions(
228
+ timeout=120, # a longer deadline for a slow method
229
+ metadata={"x-attempt": "2"},
230
+ wait_for_ready=True, # wait for the channel instead of failing UNAVAILABLE
231
+ compression=grpc.Compression.Gzip,
232
+ ),
233
+ )
234
+ ```
235
+
236
+ `timeout=None` means "no deadline at all"; an omitted `timeout` means "take the client's". They are different — the sentinel `USE_CLIENT_DEFAULT` exists precisely to tell them apart.
237
+
238
+ ## Metadata
239
+
240
+ Pass a dict, or a sequence of pairs when a key needs several values:
241
+
242
+ ```python
243
+ client.get_order(request, metadata={"x-order-id": "A-1024"})
244
+ client.get_order(request, metadata=[("x-feature", "search"), ("x-feature", "ranking")])
245
+ ```
246
+
247
+ Responses carry a `Metadata` mapping: dict-like, but it preserves order and repeated keys and compares key names case-insensitively.
248
+
249
+ ```python
250
+ response.trailing_metadata["x-request-id"] # first value for the key
251
+ response.trailing_metadata.get_all("x-feature") # every value for the key
252
+ response.trailing_metadata.pairs() # every pair, repeats included
253
+ ```
254
+
255
+ Binary values are allowed only for keys ending in `-bin` — a gRPC requirement, not a library one.
256
+
257
+ ## Authentication
258
+
259
+ The library does not know where a token comes from; it declares the contract:
260
+
261
+ ```python
262
+ class ClientAuth(Protocol):
263
+ def token(self) -> str: ...
264
+ def refresh(self, rejected_token: str) -> str: ...
265
+ ```
266
+
267
+ The implementation owns issuing, caching and expiry. The client owns the rest: it adds `authorization: Bearer <token>` to every request, and on `UNAUTHENTICATED` calls `refresh(rejected_token)` and repeats the call. The rejected token is passed so the implementation can tell "the token really expired" from "a neighbouring request already refreshed it while this one waited on the lock".
268
+
269
+ Retry happens for **unary calls only** — a request stream is exhausted after the first send, and a response stream reveals its status only once consumed. Switch it off with `options.with_auth(my_auth, retry_on_unauthenticated=False)`.
270
+
271
+ If the caller supplies its own `authorization` metadata, the interceptor stays out entirely: no token is requested and no retry is made.
272
+
273
+ Ready-made IAM authentication lives in [`grpc-client-auth`](https://github.com/cloud-ru-tech/qa-libraries/tree/master/packages/grpc-client-auth).
274
+
275
+ ## Logging
276
+
277
+ ```python
278
+ options.with_logger(
279
+ logging.getLogger("orders"),
280
+ sanitize_keys=["user.password", "items.*.token"],
281
+ json_indent=2,
282
+ )
283
+ ```
284
+
285
+ All four call types are logged. For streams the log carries the final status and the message count — the messages themselves are not logged, because reading them for the log would change the call's behaviour.
286
+
287
+ Masking rules differ by target:
288
+
289
+ - **metadata** — matched on the whole key name, case-insensitively;
290
+ - **message body** — a key with no separators (`password`) masks that field at any nesting depth, including list elements; a key containing `.`, `*`, `[` or `(` (`user.password`, `items.*.token`) is applied as a [glom path](https://glom.readthedocs.io/en/latest/).
291
+
292
+ Field names are compared up to naming style, so `access_token` also hides `accessToken` (protobuf serializes to camelCase), while a differently-worded field like `sec_ret` is left alone.
293
+
294
+ Common secrets (`authorization`, `cookie`, `password`, `token` and friends) are masked always, on top of `sanitize_keys`; turn that off with `use_default_sanitize_keys=False`.
295
+
296
+ Global defaults come from `grpc_client.configure_logging(...)` and can be overridden by `GRPC_CLIENT_*` environment variables. The full list is in `references/options.md`.
297
+
298
+ ### A log that is not a text line
299
+
300
+ `with_logger` writes one string per call. To get the same data in another shape, hand the client a `LogSink` instead:
301
+
302
+ ```python
303
+ options.with_log_sink(MySink())
304
+ ```
305
+
306
+ The interceptor builds a `RequestRecord` / `ResponseRecord` and picks the level; the sink decides where the record goes and how it looks — including masking and formatting, which is why `with_log_sink` takes no formatting parameters. Read the outcome off `record.succeeded` and `record.is_stream` rather than re-deriving it from `status_code` / `messages_count`.
307
+
308
+ A sink is called from several threads and on the hot path, and its exceptions are swallowed rather than propagated — the call survives, the record does not. Details and record shapes are in `references/options.md`.
309
+
310
+ For structlog this already exists: [`grpc-client-structlog`](https://github.com/cloud-ru-tech/qa-libraries/tree/master/packages/grpc-client-structlog).
311
+
312
+ ## Custom interceptors
313
+
314
+ Interceptors are written against the plain grpcio API. When the job is just adding metadata, subclass `MetadataInterceptor` — it covers all four call types:
315
+
316
+ ```python
317
+ from typing import Any
318
+
319
+ from grpc_client.interceptors import MetadataInterceptor
320
+ from grpc_client.types import MetadataItems
321
+
322
+
323
+ class TenantInterceptor(MetadataInterceptor):
324
+ """Adds the tenant id to every request."""
325
+
326
+ def __init__(self, tenant: str):
327
+ self._tenant = tenant
328
+
329
+ def metadata_to_add(self, call_details: Any) -> MetadataItems:
330
+ return (("x-tenant", self._tenant),)
331
+
332
+
333
+ client = OrdersClient.from_addr("localhost:9000", options.with_interceptor(TenantInterceptor("warehouse-42")))
334
+ ```
335
+
336
+ `AsyncMetadataInterceptor` is the same contract with `metadata_to_add` as a coroutine.
337
+
338
+ Helpers in `grpc_client.interceptors.base`: `outcome_code(outcome)` for the status of a finished call, `with_metadata` / `aio_call_details` to build augmented call details, `method_name(call_details)` for the `/package.Service/Method` string, `call_detail(call_details, name, default)` for a field grpcio may not provide.
339
+
340
+ An async interceptor implementing several interfaces is split into per-interface adapters when it is added — see `references/pitfalls.md` for why, and for what that means when inspecting the client.
341
+
342
+ ## Known pitfalls
343
+
344
+ Before debugging "strange" behaviour, read `references/pitfalls.md`. It covers the 60-second default deadline, stream consumption rules, when a stream raises instead of returning a response, async log draining on close, and the metadata merge order.
@@ -0,0 +1,260 @@
1
+ # Option catalog and configuration
2
+
3
+ All options live in `grpc_client.options`. Each one returns a `ClientOption` — a callable applied to the client inside `__init__`, before the channel is created.
4
+
5
+ ## Channel and transport
6
+
7
+ ### `with_timeout(timeout: float | None) -> ClientOption`
8
+
9
+ Default deadline for every call, in seconds. `None` means no deadline.
10
+
11
+ The default is `BaseGRPCClient.DEFAULT_TIMEOUT_SEC` — **60 seconds**. Bare grpcio has no default deadline; this library imposes one so a hung call cannot hold a test run until the CI job times out. Override it per client with this option, per subclass by redefining `DEFAULT_TIMEOUT_SEC`, or per call with `CallOptions(timeout=...)`.
12
+
13
+ ### `with_insecure_channel() -> ClientOption`
14
+
15
+ Plaintext channel. Everything on the wire, access tokens included, is unprotected — local debugging and test stands only.
16
+
17
+ The channel is encrypted by default: without this option the client builds a secure channel with `grpc.ssl_channel_credentials()`.
18
+
19
+ ### `with_ssl_credentials(credentials: grpc.ChannelCredentials) -> ClientOption`
20
+
21
+ Custom TLS channel credentials, e.g. carrying a corporate root certificate. Also forces `secure = True`, so combining it with `with_insecure_channel()` makes the outcome depend on the order — the later option wins.
22
+
23
+ ### `with_root_certificates(root_certificates: bytes) -> ClientOption`
24
+
25
+ Shorthand for `with_ssl_credentials(grpc.ssl_channel_credentials(root_certificates=...))`. Takes the contents of a PEM file, not a path.
26
+
27
+ ### `with_channel_options(options: ChannelOptions) -> ClientOption`
28
+
29
+ Low-level grpcio channel options. Replaces the whole tuple rather than appending, so pass every option in one call.
30
+
31
+ This is where grpcio's built-in retries are configured — do not hand-roll a retry loop:
32
+
33
+ ```python
34
+ import json
35
+
36
+ options.with_channel_options(
37
+ [
38
+ ("grpc.enable_retries", 1),
39
+ (
40
+ "grpc.service_config",
41
+ json.dumps(
42
+ {
43
+ "methodConfig": [
44
+ {
45
+ "name": [{"service": "example.Orders"}],
46
+ "retryPolicy": {
47
+ "maxAttempts": 4,
48
+ "initialBackoff": "0.1s",
49
+ "maxBackoff": "1s",
50
+ "backoffMultiplier": 2,
51
+ "retryableStatusCodes": ["UNAVAILABLE"],
52
+ },
53
+ }
54
+ ],
55
+ }
56
+ ),
57
+ ),
58
+ ]
59
+ )
60
+ ```
61
+
62
+ ### `with_compression(compression: grpc.Compression) -> ClientOption`
63
+
64
+ Message compression. Applied both when creating the channel and as the default for each call, so a per-call `CallOptions(compression=...)` overrides it.
65
+
66
+ ## Metadata
67
+
68
+ ### `with_default_metadata(metadata: MetadataType) -> ClientOption`
69
+
70
+ Metadata appended to every request. Accumulates: applying the option twice keeps both sets, in order of application.
71
+
72
+ These pairs go in front of any per-call metadata. Interceptor-added metadata is appended after both, since interceptors run on the channel.
73
+
74
+ ### `with_autogenerated_request_id(key: str | None = None) -> ClientOption`
75
+
76
+ Adds a UUID4 under `x-request-id`. The key can be changed by argument or by the `GRPC_CLIENT_REQUEST_ID_KEY` environment variable.
77
+
78
+ If the request already carries that key, the existing value is kept.
79
+
80
+ ### `with_autogenerated_traceparent(key: str | None = None) -> ClientOption`
81
+
82
+ Adds a W3C Trace Context value `00-<32 hex>-<16 hex>-01` under `traceparent`. Key overridable by argument or by `GRPC_CLIENT_TRACEPARENT_KEY`.
83
+
84
+ This is standalone generation, not an OpenTelemetry integration: no context is picked up from a current span. An existing value is kept.
85
+
86
+ ## Authentication
87
+
88
+ ### `with_auth(auth: ClientAuth | AsyncClientAuth, *, retry_on_unauthenticated: bool = True) -> ClientOption`
89
+
90
+ Injects `authorization: Bearer <token>` into every request.
91
+
92
+ The implementation is picked by client type, and the two protocols are told apart by whether `token()` is a coroutine function — they declare identical method names, so `isinstance` cannot distinguish them (which is why neither protocol is `runtime_checkable`). A mismatch raises `TypeError` at client construction:
93
+
94
+ - sync client + async implementation → `Синхронному клиенту нужна синхронная реализация ClientAuth`
95
+ - async client + sync implementation → `Асинхронному клиенту нужна асинхронная реализация AsyncClientAuth`
96
+
97
+ With `retry_on_unauthenticated=True` (default) an `UNAUTHENTICATED` reply triggers `refresh(rejected_token)` and one repeat — **unary calls only**.
98
+
99
+ The key and scheme are fixed at `authorization` / `Bearer` in the option. For other values instantiate `AuthInterceptor(auth, key=..., scheme=...)` directly and pass it via `with_interceptor`.
100
+
101
+ ## Interceptors
102
+
103
+ ### `with_interceptor(*interceptors: object) -> ClientOption`
104
+
105
+ Adds grpcio interceptors, sync or async depending on the client. They are applied in the order added.
106
+
107
+ For an async client each interceptor goes through `split_async_interceptor()` first — see `pitfalls.md`.
108
+
109
+ ## Logging
110
+
111
+ ### `with_logger(logger, *, sanitize_keys=None, log_level=None, err_log_level=None, json_indent=None, use_line_breaks=None, payload_maxlength=None, use_default_sanitize_keys=True) -> ClientOption`
112
+
113
+ | Parameter | Type | Meaning |
114
+ |---|---|---|
115
+ | `logger` | `logging.Logger` | where records are written |
116
+ | `sanitize_keys` | `Sequence[str] \| None` | metadata key names and message-body paths whose values become `****` |
117
+ | `log_level` | `int \| None` | level for successful calls; defaults to the global config |
118
+ | `err_log_level` | `int \| None` | level for failed calls; defaults to the global config |
119
+ | `json_indent` | `int \| None` | indent for the JSON parts of a record |
120
+ | `use_line_breaks` | `bool \| None` | newlines between record parts instead of spaces |
121
+ | `payload_maxlength` | `int \| None` | maximum message-body length in a record |
122
+ | `use_default_sanitize_keys` | `bool` | also mask the built-in secret list (default `True`) |
123
+
124
+ Record shapes:
125
+
126
+ ```
127
+ GRPC request sent: id=<uuid> Method: /package.Service/Method Payload: {...} Metadata: {...}
128
+ GRPC response received: id=<uuid> Status: 0 (OK) Method: /package.Service/Method Payload: {...} Metadata: {...}
129
+ GRPC stream finished: id=<uuid> Status: 0 (OK) Method: /package.Service/Method Messages: 12 Metadata: {...}
130
+ GRPC error occurred: id=<uuid> Status: 5 (NOT_FOUND) Method: /package.Service/Method Error: <details> Metadata: {...}
131
+ ```
132
+
133
+ `id` links a request to its response. It is generated per call by the interceptor and has nothing to do with server-side tracing — for that use `with_autogenerated_traceparent`.
134
+
135
+ Notes on what lands in a record:
136
+
137
+ - a client request stream logs `Payload: <поток сообщений>` — the iterator is not consumed for the log;
138
+ - a server response stream logs no payload at all, only the final status and message count;
139
+ - a body that cannot be rendered becomes `<не удалось подготовить тело сообщения>`; a body over `payload_maxlength` is cut with `... <обрезано>`;
140
+ - a repeated metadata key is rendered as a list of its values, so the record matches what was actually sent;
141
+ - when the logger is disabled for both levels the interceptor short-circuits: no id is generated and streams are not wrapped for counting.
142
+
143
+ ### `with_log_sink(sink, log_level=None, err_log_level=None) -> ClientOption`
144
+
145
+ The extension point behind `with_logger`. The interceptor decides **what happened** — it builds a record and picks the level — and the sink decides **where and in what shape** it lands.
146
+
147
+ | Parameter | Type | Meaning |
148
+ |---|---|---|
149
+ | `sink` | `LogSink` | receiver of the records |
150
+ | `log_level` | `int \| None` | level for successful calls; defaults to the global config |
151
+ | `err_log_level` | `int \| None` | level for failed calls; defaults to the global config |
152
+
153
+ `LogSink` is a `Protocol` — three methods, no base class required, though subclassing it explicitly buys you mypy signature checks and an error on a forgotten method:
154
+
155
+ ```python
156
+ class LogSink(Protocol):
157
+ def is_enabled_for(self, level: int) -> bool: ...
158
+ def write_request(self, level: int, record: RequestRecord) -> None: ...
159
+ def write_response(self, level: int, record: ResponseRecord) -> None: ...
160
+ ```
161
+
162
+ `RequestRecord` is `(trace_id, method, message, metadata)`; `message` is `None` when the request body was a stream.
163
+
164
+ `ResponseRecord` is `(trace_id, method, status_code, message, details, metadata, messages_count)` and covers all three outcomes with one shape:
165
+
166
+ | Outcome | `status_code` | `message` | `details` | `messages_count` |
167
+ |---|---|---|---|---|
168
+ | unary success | `OK` | the response | `""` | `None` |
169
+ | stream finished | `OK` | `None` | `""` | count |
170
+ | any failure | the error code | `None` | service error text | count for streams, `None` for unary |
171
+
172
+ Do not re-derive the outcome from that combination — the record answers it: `record.succeeded` (status is `OK`) and `record.is_stream` (`messages_count` is set). `TextLogSink` picks its three text shapes off exactly those two.
173
+
174
+ `is_enabled_for` is asked **before** the record is built — assembling a body costs `MessageToDict` plus masking plus `json.dumps`, the question does not. When both levels answer `False` the interceptor short-circuits entirely: no `trace_id`, no stream wrapper.
175
+
176
+ Three constraints on an implementation:
177
+
178
+ - **it is called from several threads** — the final stream log comes from a grpcio channel thread, the async one from an event-loop task;
179
+ - **it is called on the hot path** — the request record is written before the call is sent, so slow work inside the sink delays the call itself;
180
+ - **it must not count on being able to fail** — an exception is swallowed and reported to the `grpc_client.interceptors.logging` logger, the call goes through, and that one record is lost.
181
+
182
+ Formatting is the sink's own business, so `with_log_sink` has no `sanitize_keys` / `json_indent` / `payload_maxlength` parameters. `grpc_client.LogFormat.from_config(...)` builds the rules with the `configure_logging` values filled in for anything left `None`, and `grpc_client.utils.logutils` exposes the helpers that turn a record into text.
183
+
184
+ `TextLogSink(logger, log_format=None)` is the built-in implementation; `with_logger(...)` is shorthand for constructing it. `grpc_client.resolve_log_levels(log_level, err_log_level)` returns the pair of levels the interceptor will use — needed when a sink must reject an unusable level before the client is built.
185
+
186
+ ### Masking
187
+
188
+ Built-in secret list (`grpc_client.utils.sanitizer.DEFAULT_SANITIZE_KEYS`): `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `api-key`, `api_key`, `access_token`, `refresh_token`, `password`, `secret`, `token`. Anything in `sanitize_keys` is added on top; turn the defaults off only with `use_default_sanitize_keys=False`.
189
+
190
+ Metadata and message bodies are masked by different rules:
191
+
192
+ | Target | Rule |
193
+ |---|---|
194
+ | metadata | whole key name, case-insensitive; no path parsing, because gRPC metadata is flat and its keys may contain dots |
195
+ | body, key without separators (`password`) | that field name at any nesting depth, list elements included |
196
+ | body, key with `.` `*` `[` `(` (`user.password`, `items.*.token`) | applied as a glom path |
197
+
198
+ Name matching is style-insensitive, resolved by normalizing to snake_case: `access_token` also hides `accessToken`, `APIKey`, `token2FA`. Word boundaries are respected, so `secret` does not match `sec_ret` and `token` does not match `tokenizer`. A compound name ending in a secret does match: `user_password` and `client_secret` are masked by `password` and `secret`.
199
+
200
+ Body traversal is depth-limited at 64 levels; anything deeper is replaced with `<превышена глубина вложенности>` rather than passed through, since it may still hold a secret.
201
+
202
+ Masking covers the records this library writes. It does not cover grpcio's own exception text.
203
+
204
+ ### `configure_logging(...)` — global defaults
205
+
206
+ ```python
207
+ from grpc_client import configure_logging
208
+
209
+ configure_logging(
210
+ log_level=logging.INFO,
211
+ err_log_level=logging.ERROR,
212
+ json_indent=None,
213
+ use_line_breaks=False,
214
+ payload_log_maxlength=4096,
215
+ )
216
+ ```
217
+
218
+ Writes to the module-level singleton `grpc_client.config.LOGGING_CONFIG`, which the interceptors read whenever the matching `with_logger` argument was omitted.
219
+
220
+ **Environment variables override the function arguments**, not the other way round:
221
+
222
+ | Variable | Field |
223
+ |---|---|
224
+ | `GRPC_CLIENT_LOG_LEVEL` | `log_level` (integer) |
225
+ | `GRPC_CLIENT_ERR_LOG_LEVEL` | `err_log_level` (integer) |
226
+ | `GRPC_CLIENT_JSON_INDENT` | `json_indent` |
227
+ | `GRPC_CLIENT_USE_LINE_BREAKS` | `use_line_breaks` (`true`/`y`/`yes`/`on`/`1`) |
228
+ | `GRPC_CLIENT_PAYLOAD_LOG_MAXLENGTH` | `payload_log_maxlength` |
229
+
230
+ Two more variables are read by the observability interceptors, not by `configure_logging`: `GRPC_CLIENT_REQUEST_ID_KEY` and `GRPC_CLIENT_TRACEPARENT_KEY`.
231
+
232
+ ## Utilities
233
+
234
+ `grpc_client.utils.sanitizer`
235
+ - `sanitize(value, sensitive_keys)` — masks a dict or list by the rules above; returns a copy, leaves the original untouched.
236
+ - `sanitize_metadata(metadata, sensitive_keys)` — the same for metadata pairs.
237
+ - `DEFAULT_SANITIZE_KEYS`, `MASK`, `MAX_DEPTH`.
238
+
239
+ `grpc_client.utils.traceparent`
240
+ - `generate(flags=FLAGS_SAMPLED)` — a W3C Trace Context string.
241
+
242
+ `grpc_client.utils.logutils`
243
+ - `request_log`, `response_log`, `stream_response_log`, `error_log` — build the same strings by hand, e.g. from a logger on a different backend.
244
+ - `LogFormat` — the formatting rules those four take.
245
+
246
+ ## Client attributes
247
+
248
+ | Attribute | Where it is set | Purpose |
249
+ |---|---|---|
250
+ | `DEFAULT_TIMEOUT_SEC` | class attribute, overridable in a subclass | default deadline, `60.0` |
251
+ | `timeout` | `with_timeout`, read per call | current default deadline |
252
+ | `secure` | `with_insecure_channel` / `with_ssl_credentials` | whether the channel is encrypted |
253
+ | `ssl_credentials` | `with_ssl_credentials` | TLS channel credentials |
254
+ | `compression` | `with_compression` | default compression |
255
+ | `channel_options` | `with_channel_options` | grpcio channel options |
256
+ | `default_metadata` | `with_default_metadata` | metadata added to every request |
257
+ | `channel` | built in `__init__` | the underlying `grpc.Channel` / `grpc.aio.Channel` |
258
+ | `stub` | built in `__init__` | the generated stub over that channel |
259
+
260
+ `timeout`, `default_metadata` and `compression` are read when a call is assembled, so changing them after construction does take effect — but do it through options anyway, since the channel-level fields around them do not behave that way.