Skip to content

Commit 343c42d

Browse files
authored
feat: add user agent header using gRPC interceptor (#2199)
Signed-off-by: oGranny <ogranny.github.io@gmail.com>
1 parent 34d9e8a commit 343c42d

4 files changed

Lines changed: 192 additions & 1 deletion

File tree

src/hiero_sdk_python/channels.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
from __future__ import annotations
22

3+
from collections import namedtuple
4+
from importlib.metadata import PackageNotFoundError, version
5+
6+
import grpc
7+
38
from hiero_sdk_python.hapi.services import (
49
address_book_service_pb2_grpc,
510
consensus_service_pb2_grpc,
@@ -14,6 +19,84 @@
1419
)
1520

1621

22+
class _UserAgentInterceptor(grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor):
23+
"""
24+
gRPC interceptor that appends an x-user-agent header to all outgoing requests.
25+
"""
26+
27+
_HEADER_KEY = "x-user-agent"
28+
_SDK_NAME = "hiero-sdk-python"
29+
_CallDetails = namedtuple(
30+
"_CallDetails",
31+
("method", "timeout", "metadata", "credentials", "wait_for_ready", "compression"),
32+
)
33+
34+
def __init__(self) -> None:
35+
"""
36+
Initialize the interceptor and compute the user agent value.
37+
The user agent is computed once during initialization to avoid repeated package metadata lookups on every request.
38+
"""
39+
40+
try:
41+
sdk_version = version(self._SDK_NAME)
42+
except PackageNotFoundError:
43+
sdk_version = "dev"
44+
self._user_agent = f"{self._SDK_NAME}/{sdk_version}"
45+
46+
def _with_user_agent(self, details: grpc.ClientCallDetails) -> grpc.ClientCallDetails:
47+
"""
48+
Append the user agent header to the call details.
49+
50+
Args:
51+
details: The original gRPC call details.
52+
53+
Returns:
54+
A new ClientCallDetails object with the x-user-agent header included in the metadata.
55+
"""
56+
metadata = [] if details.metadata is None else list(details.metadata)
57+
metadata = [entry for entry in metadata if entry[0] != self._HEADER_KEY]
58+
metadata.append((self._HEADER_KEY, self._user_agent))
59+
60+
return self._CallDetails(
61+
details.method,
62+
details.timeout,
63+
metadata,
64+
getattr(details, "credentials", None),
65+
getattr(details, "wait_for_ready", None),
66+
getattr(details, "compression", None),
67+
)
68+
69+
def intercept_unary_unary(self, continuation, client_call_details, request):
70+
"""
71+
Intercept unary-unary calls and append the user agent header.
72+
73+
Args:
74+
continuation: The gRPC continuation function to call the next interceptor or actual RPC.
75+
client_call_details: The details of the gRPC call, including method, timeout, metadata, etc.
76+
request: The request object being sent.
77+
78+
Returns:
79+
The result of the gRPC call after appending the user agent header.
80+
"""
81+
82+
return continuation(self._with_user_agent(client_call_details), request)
83+
84+
def intercept_unary_stream(self, continuation, client_call_details, request):
85+
"""
86+
Intercept unary-stream calls and append the user agent header.
87+
88+
Args:
89+
continuation: The gRPC continuation function to call the next interceptor or actual RPC.
90+
client_call_details: The details of the gRPC call, including method, timeout, metadata, etc.
91+
request: The request object being sent.
92+
93+
Returns:
94+
The result of the gRPC call after appending the user agent header.
95+
"""
96+
97+
return continuation(self._with_user_agent(client_call_details), request)
98+
99+
17100
class _Channel:
18101
"""
19102
The _Channel class is a wrapper around gRPC channels that provides access to various

src/hiero_sdk_python/client/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from dotenv import load_dotenv
1313

1414
from hiero_sdk_python.account.account_id import AccountId
15+
from hiero_sdk_python.channels import _UserAgentInterceptor
1516
from hiero_sdk_python.crypto.private_key import PrivateKey
1617
from hiero_sdk_python.hapi.mirror import (
1718
consensus_service_pb2_grpc as mirror_consensus_grpc,
@@ -163,6 +164,8 @@ def _init_mirror_stub(self) -> None:
163164
self.mirror_channel = grpc.secure_channel(mirror_address, grpc.ssl_channel_credentials())
164165
else:
165166
self.mirror_channel = grpc.insecure_channel(mirror_address)
167+
168+
self.mirror_channel = grpc.intercept_channel(self.mirror_channel, _UserAgentInterceptor())
166169
self.mirror_stub = mirror_consensus_grpc.ConsensusServiceStub(self.mirror_channel)
167170

168171
def set_operator(self, account_id: AccountId, private_key: PrivateKey) -> None:

src/hiero_sdk_python/node.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from hiero_sdk_python.account.account_id import AccountId
1111
from hiero_sdk_python.address_book.node_address import NodeAddress
12-
from hiero_sdk_python.channels import _Channel
12+
from hiero_sdk_python.channels import _Channel, _UserAgentInterceptor
1313
from hiero_sdk_python.managed_node_address import _ManagedNodeAddress
1414

1515

@@ -147,6 +147,8 @@ def _get_channel(self):
147147
else:
148148
channel = grpc.insecure_channel(str(self._address))
149149

150+
channel = grpc.intercept_channel(channel, _UserAgentInterceptor())
151+
150152
self._channel = _Channel(channel)
151153

152154
return self._channel
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from __future__ import annotations
2+
3+
import grpc
4+
import pytest
5+
6+
from hiero_sdk_python import channels
7+
from hiero_sdk_python.channels import _UserAgentInterceptor
8+
9+
10+
pytestmark = pytest.mark.unit
11+
12+
13+
class _DummyCallDetails:
14+
def __init__(
15+
self,
16+
metadata=None,
17+
method: str = "/proto.Service/Method",
18+
timeout: float | None = None,
19+
credentials=None,
20+
wait_for_ready: bool | None = None,
21+
compression=None,
22+
):
23+
self.method = method
24+
self.timeout = timeout
25+
self.metadata = metadata
26+
self.credentials = credentials
27+
self.wait_for_ready = wait_for_ready
28+
self.compression = compression
29+
30+
31+
def test_user_agent_value_from_installed_version(monkeypatch):
32+
"""Interceptor should build x-user-agent from package version when available."""
33+
34+
monkeypatch.setattr(channels, "version", lambda _name: "0.0.0")
35+
36+
interceptor = _UserAgentInterceptor()
37+
assert interceptor._user_agent == "hiero-sdk-python/0.0.0"
38+
39+
40+
def test_user_agent_value_falls_back_to_dev(monkeypatch):
41+
"""Interceptor should fall back to dev when package metadata is unavailable."""
42+
43+
def _raise_not_found(_name):
44+
raise channels.PackageNotFoundError
45+
46+
monkeypatch.setattr(channels, "version", _raise_not_found)
47+
48+
interceptor = _UserAgentInterceptor()
49+
assert interceptor._user_agent == "hiero-sdk-python/dev"
50+
51+
52+
def test_intercept_unary_unary_appends_user_agent_metadata(monkeypatch):
53+
"""Unary calls should forward metadata including x-user-agent."""
54+
55+
monkeypatch.setattr(channels, "version", lambda _name: "0.0.0")
56+
57+
interceptor = _UserAgentInterceptor()
58+
request = object()
59+
captured = {}
60+
61+
def continuation(client_call_details, req):
62+
captured["details"] = client_call_details
63+
captured["request"] = req
64+
return "ok"
65+
66+
details = _DummyCallDetails(metadata=[("existing", "value")])
67+
result = interceptor.intercept_unary_unary(continuation, details, request)
68+
69+
assert result == "ok"
70+
assert captured["request"] is request
71+
assert ("existing", "value") in captured["details"].metadata
72+
expected_header = (interceptor._HEADER_KEY, f"{interceptor._SDK_NAME}/0.0.0")
73+
assert expected_header in captured["details"].metadata
74+
75+
76+
def test_intercept_unary_stream_adds_metadata_when_none(monkeypatch):
77+
"""Unary-stream calls should work even when original metadata is None."""
78+
79+
monkeypatch.setattr(channels, "version", lambda _name: "0.0.0")
80+
81+
interceptor = _UserAgentInterceptor()
82+
request = object()
83+
captured = {}
84+
85+
def continuation(client_call_details, req):
86+
captured["details"] = client_call_details
87+
captured["request"] = req
88+
return "stream"
89+
90+
details = _DummyCallDetails(metadata=None)
91+
result = interceptor.intercept_unary_stream(continuation, details, request)
92+
93+
assert result == "stream"
94+
assert captured["request"] is request
95+
expected_header = (interceptor._HEADER_KEY, f"{interceptor._SDK_NAME}/0.0.0")
96+
assert captured["details"].metadata == [expected_header]
97+
98+
99+
def test_interceptor_implements_required_grpc_interfaces():
100+
"""Guard against accidental loss of gRPC interceptor interface inheritance."""
101+
interceptor = _UserAgentInterceptor()
102+
assert isinstance(interceptor, grpc.UnaryUnaryClientInterceptor)
103+
assert isinstance(interceptor, grpc.UnaryStreamClientInterceptor)

0 commit comments

Comments
 (0)