Skip to content

Commit 91891f1

Browse files
committed
fix(async-watcher): forward auth metadata to GetConfig and Subscribe
AsyncConfigClient cannot use channel interceptors, so it injects auth metadata per-call via self._metadata(). AsyncConfigWatcher was not receiving this metadata, causing both the initial GetConfig snapshot and the Subscribe streaming call to omit auth headers. Auth-enabled servers rejected the Subscribe with UNAUTHENTICATED. Added a metadata parameter to AsyncConfigWatcher.__init__ and thread it through both _load_snapshot and _subscribe_loop. Updated AsyncConfigClient.watch() to pass self._metadata(). Added a unit test asserting that both gRPC calls receive the correct metadata tuples. Co-Authored-By: Claude <noreply@anthropic.com> Closes #48
1 parent e546392 commit 91891f1

3 files changed

Lines changed: 44 additions & 2 deletions

File tree

sdk/src/opendecree/async_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,4 +373,4 @@ def watch(self, tenant_id: str) -> AsyncConfigWatcher:
373373
"""
374374
from opendecree.async_watcher import AsyncConfigWatcher
375375

376-
return AsyncConfigWatcher(self._stub, self._pb2, tenant_id, self._timeout)
376+
return AsyncConfigWatcher(self._stub, self._pb2, tenant_id, self._timeout, self._metadata())

sdk/src/opendecree/async_watcher.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,19 @@ class AsyncConfigWatcher:
129129
auto-starts on enter, auto-stops on exit.
130130
"""
131131

132-
def __init__(self, stub: Any, pb2: Any, tenant_id: str, timeout: float) -> None:
132+
def __init__(
133+
self,
134+
stub: Any,
135+
pb2: Any,
136+
tenant_id: str,
137+
timeout: float,
138+
metadata: list[tuple[str, str]] | None = None,
139+
) -> None:
133140
self._stub = stub
134141
self._pb2 = pb2
135142
self._tenant_id = tenant_id
136143
self._timeout = timeout
144+
self._metadata: list[tuple[str, str]] = metadata or []
137145
self._fields: dict[str, AsyncWatchedField] = {} # type: ignore[type-arg]
138146
self._task: asyncio.Task | None = None # type: ignore[type-arg]
139147
self._stopped = False
@@ -193,6 +201,7 @@ async def _load_snapshot(self) -> None:
193201
resp = await self._stub.GetConfig(
194202
self._pb2.GetConfigRequest(tenant_id=self._tenant_id),
195203
timeout=self._timeout,
204+
metadata=self._metadata,
196205
)
197206
all_values = process_get_all_response(resp)
198207
for path, watched in self._fields.items():
@@ -211,6 +220,7 @@ async def _subscribe_loop(self) -> None:
211220
tenant_id=self._tenant_id,
212221
field_paths=field_paths,
213222
),
223+
metadata=self._metadata,
214224
)
215225
backoff = _RECONNECT_INITIAL
216226

sdk/tests/test_async_watcher.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,35 @@ async def test_non_retryable_error_stops_loop(self):
270270
assert w._task is not None
271271
assert w._task.done()
272272
await w.stop()
273+
274+
@pytest.mark.asyncio
275+
async def test_auth_metadata_forwarded(self):
276+
"""metadata= is passed to both GetConfig and Subscribe."""
277+
stub = MagicMock()
278+
pb2 = MagicMock()
279+
280+
mock_resp = MagicMock()
281+
mock_resp.config.values = []
282+
stub.GetConfig = AsyncMock(return_value=mock_resp)
283+
284+
auth_meta = [("x-subject", "svc"), ("x-role", "superadmin")]
285+
w = AsyncConfigWatcher(stub, pb2, "t1", timeout=5.0, metadata=auth_meta)
286+
w.field("fee", float, default=0.0)
287+
288+
async def empty_stream():
289+
return
290+
yield
291+
292+
stub.Subscribe.return_value = empty_stream()
293+
294+
await w.start()
295+
await asyncio.sleep(0.05)
296+
await w.stop()
297+
298+
stub.GetConfig.assert_awaited_once()
299+
_, get_kwargs = stub.GetConfig.call_args
300+
assert get_kwargs.get("metadata") == auth_meta
301+
302+
stub.Subscribe.assert_called_once()
303+
_, sub_kwargs = stub.Subscribe.call_args
304+
assert sub_kwargs.get("metadata") == auth_meta

0 commit comments

Comments
 (0)