Skip to content

Commit 56a29eb

Browse files
RobLe3claude
andcommitted
feat: SDK-06 W3C traceparent propagation; closes #1
Add _traceparent() to _http.py — generates 00-<32hex>-<16hex>-01 per W3C Trace Context spec. Inject into every get_json() and post_json() call. In submit_async(), generate one traceparent shared across the discover GET and node POST so both spans share the same trace-id. Two new tests: header format validation + shared trace-id within submit(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0e52f41 commit 56a29eb

3 files changed

Lines changed: 65 additions & 3 deletions

File tree

src/iicp_client/_http.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Internal HTTP helpers — TLS context, timeout normalization."""
22
from __future__ import annotations
33

4+
import secrets
45
import ssl
56
import time
67
from typing import Any
@@ -10,6 +11,17 @@
1011
from iicp_client.errors import IicpError, from_http
1112

1213

14+
def _traceparent() -> str:
15+
"""Generate a W3C traceparent header value (SDK-06).
16+
17+
Format: 00-<trace-id>-<parent-id>-01
18+
trace-id = 16 random bytes as 32 hex chars
19+
parent-id = 8 random bytes as 16 hex chars
20+
flags = 01 (sampled)
21+
"""
22+
return f"00-{secrets.token_hex(16)}-{secrets.token_hex(8)}-01"
23+
24+
1325
def _tls_context(verify: bool) -> ssl.SSLContext | bool:
1426
if not verify:
1527
# SDK-05: tls_verify=False only permitted in debug; prod builds must verify
@@ -27,13 +39,15 @@ async def get_json(
2739
timeout_ms: int = 5_000,
2840
component: str = "directory",
2941
tls_verify: bool = True,
42+
traceparent: str | None = None,
3043
) -> dict[str, Any]:
3144
timeout = timeout_ms / 1000.0
45+
headers = {"traceparent": traceparent or _traceparent()}
3246
try:
3347
async with httpx.AsyncClient(
3448
timeout=timeout, verify=_tls_context(tls_verify)
3549
) as client:
36-
resp = await client.get(url, params=params)
50+
resp = await client.get(url, params=params, headers=headers)
3751
except httpx.TimeoutException:
3852
raise IicpError(
3953
code="IICP-E003",
@@ -60,15 +74,17 @@ async def post_json(
6074
timeout_ms: int = 30_000,
6175
component: str = "adapter",
6276
tls_verify: bool = True,
77+
traceparent: str | None = None,
6378
) -> tuple[dict[str, Any], int]:
6479
"""Returns (response_body, elapsed_ms)."""
6580
timeout = (timeout_ms / 1000.0) + 2.0
81+
headers = {"traceparent": traceparent or _traceparent()}
6682
t0 = time.monotonic()
6783
try:
6884
async with httpx.AsyncClient(
6985
timeout=timeout, verify=_tls_context(tls_verify)
7086
) as client:
71-
resp = await client.post(url, json=body)
87+
resp = await client.post(url, json=body, headers=headers)
7288
except httpx.TimeoutException:
7389
raise IicpError(
7490
code="IICP-E003",

src/iicp_client/client.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import uuid
77
from typing import Any
88

9-
from iicp_client._http import get_json, post_json
9+
from iicp_client._http import _traceparent, get_json, post_json
1010
from iicp_client.errors import IicpError
1111
from iicp_client.types import (
1212
ChatChoice,
@@ -51,6 +51,8 @@ async def discover_async(
5151
self,
5252
intent: str,
5353
options: DiscoverOptions | None = None,
54+
*,
55+
traceparent: str | None = None,
5456
) -> NodeList:
5557
"""Discover nodes capable of handling *intent*."""
5658
opts = options or DiscoverOptions()
@@ -73,6 +75,7 @@ async def discover_async(
7375
timeout_ms=5_000,
7476
component="directory",
7577
tls_verify=self._cfg.tls_verify,
78+
traceparent=traceparent,
7679
)
7780
elapsed = int((time.monotonic() - t0) * 1000)
7881

@@ -95,14 +98,18 @@ async def submit_async(self, request: TaskRequest) -> TaskResponse:
9598
"""Discover → select best node → submit task.
9699
97100
Retries up to max_retries on transient errors (SDK-01).
101+
A single W3C traceparent is generated per submit call and propagated
102+
to both the discover request and the node POST (SDK-06).
98103
"""
99104
self._validate_intent(request.intent)
105+
tp = _traceparent() # SDK-06: one trace per operation, shared across calls
100106
node_list = await self.discover_async(
101107
request.intent,
102108
DiscoverOptions(
103109
region=request.constraints.region or self._cfg.region,
104110
qos=request.constraints.qos,
105111
),
112+
traceparent=tp,
106113
)
107114
if not node_list.nodes:
108115
raise IicpError(
@@ -135,6 +142,7 @@ async def submit_async(self, request: TaskRequest) -> TaskResponse:
135142
timeout_ms=request.constraints.timeout_ms,
136143
component="adapter",
137144
tls_verify=self._cfg.tls_verify,
145+
traceparent=tp,
138146
)
139147
return TaskResponse(
140148
task_id=raw.get("task_id", task_id),

tests/test_client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,41 @@ def test_discover_passes_min_reputation_and_model():
234234
url = str(route.calls[0].request.url)
235235
assert "min_reputation=0.7" in url
236236
assert "model=phi3%3Amini" in url or "model=phi3:mini" in url
237+
238+
239+
# ---------------------------------------------------------------------------
240+
# SDK-06: W3C traceparent propagation
241+
# ---------------------------------------------------------------------------
242+
243+
244+
@respx.mock
245+
def test_sdk06_traceparent_sent_on_discover():
246+
"""SDK-06: every outbound request carries a W3C traceparent header."""
247+
route = respx.get(DISCOVER_URL).mock(return_value=httpx.Response(200, json=GOOD_NODES))
248+
client = IicpClient(ClientConfig(directory_url=DIRECTORY))
249+
client.discover("urn:iicp:intent:llm:chat:v1")
250+
header = route.calls[0].request.headers.get("traceparent", "")
251+
# format: 00-<32hex>-<16hex>-01
252+
parts = header.split("-")
253+
assert len(parts) == 4, f"bad traceparent: {header!r}"
254+
assert parts[0] == "00"
255+
assert len(parts[1]) == 32
256+
assert len(parts[2]) == 16
257+
assert parts[3] == "01"
258+
259+
260+
@respx.mock
261+
def test_sdk06_traceparent_shared_across_submit():
262+
"""SDK-06: discover + node POST share the same trace-id within one submit()."""
263+
disc_route = respx.get(DISCOVER_URL).mock(return_value=httpx.Response(200, json=GOOD_NODES))
264+
task_route = respx.post(TASK_URL).mock(
265+
return_value=httpx.Response(200, json={"task_id": "t1", "status": "success", "result": {}})
266+
)
267+
client = IicpClient(ClientConfig(directory_url=DIRECTORY))
268+
client.submit(TaskRequest(intent="urn:iicp:intent:llm:chat:v1", payload={}))
269+
disc_tp = disc_route.calls[0].request.headers.get("traceparent", "")
270+
task_tp = task_route.calls[0].request.headers.get("traceparent", "")
271+
# both must have the same trace-id (index 1)
272+
assert disc_tp.split("-")[1] == task_tp.split("-")[1], (
273+
f"trace-id mismatch: discover={disc_tp!r} task={task_tp!r}"
274+
)

0 commit comments

Comments
 (0)