agentreplay 0.1.2__py3-none-any.whl

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.
@@ -0,0 +1,328 @@
1
+ # Copyright 2025 Sushanth (https://github.com/sushanthpy)
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """W3C Trace Context propagation for distributed tracing.
16
+
17
+ This module implements W3C Trace Context specification for propagating
18
+ trace context across process boundaries using HTTP headers.
19
+
20
+ Reference: https://www.w3.org/TR/trace-context/
21
+
22
+ The W3C Trace Context uses two headers:
23
+ traceparent: 00-<trace-id>-<parent-id>-<trace-flags>
24
+ tracestate: vendor-specific key-value pairs
25
+
26
+ Example:
27
+ >>> from agentreplay.propagation import inject_trace_context, extract_trace_context
28
+ >>>
29
+ >>> # Injecting context into outgoing HTTP request
30
+ >>> headers = {}
31
+ >>> inject_trace_context(headers, trace_id=0x123abc, span_id=0x456def)
32
+ >>> # headers now contains: {"traceparent": "00-0000...123abc-00...456def-01"}
33
+ >>>
34
+ >>> # Extracting context from incoming HTTP request
35
+ >>> trace_id, parent_id, trace_flags = extract_trace_context(request.headers)
36
+ """
37
+
38
+ import logging
39
+ from typing import Dict, Optional, Tuple
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ def inject_trace_context(
45
+ headers: Dict[str, str],
46
+ trace_id: int,
47
+ span_id: int,
48
+ trace_flags: int = 0x01,
49
+ agentreplay_tenant_id: Optional[int] = None,
50
+ agentreplay_project_id: Optional[int] = None,
51
+ ) -> None:
52
+ """Inject W3C Trace Context into HTTP headers.
53
+
54
+ Adds traceparent and tracestate headers following W3C specification.
55
+
56
+ Args:
57
+ headers: Dict to inject headers into (modified in-place)
58
+ trace_id: 128-bit trace ID
59
+ span_id: 64-bit span ID
60
+ trace_flags: 8-bit trace flags (default: 0x01 = sampled)
61
+ agentreplay_tenant_id: Optional tenant ID for tracestate
62
+ agentreplay_project_id: Optional project ID for tracestate
63
+
64
+ Example:
65
+ >>> import httpx
66
+ >>> headers = {}
67
+ >>> inject_trace_context(headers, trace_id=0x123, span_id=0x456)
68
+ >>> response = httpx.get("https://api.example.com", headers=headers)
69
+ """
70
+ # Format traceparent: version-trace_id-parent_id-trace_flags
71
+ # version: 00 (fixed)
72
+ # trace_id: 32 hex characters (128 bits)
73
+ # parent_id: 16 hex characters (64 bits)
74
+ # trace_flags: 2 hex characters (8 bits)
75
+
76
+ # Convert IDs to hex strings (zero-padded)
77
+ trace_id_hex = f"{trace_id:032x}"
78
+ span_id_hex = f"{span_id:016x}"
79
+ trace_flags_hex = f"{trace_flags:02x}"
80
+
81
+ traceparent = f"00-{trace_id_hex}-{span_id_hex}-{trace_flags_hex}"
82
+ headers["traceparent"] = traceparent
83
+
84
+ # Build tracestate with vendor-specific data
85
+ tracestate_parts = []
86
+
87
+ if agentreplay_tenant_id is not None:
88
+ tracestate_parts.append(f"agentreplay=t{agentreplay_tenant_id}")
89
+
90
+ if agentreplay_project_id is not None:
91
+ if tracestate_parts:
92
+ # Append to existing agentreplay state
93
+ tracestate_parts[0] += f"p{agentreplay_project_id}"
94
+ else:
95
+ tracestate_parts.append(f"agentreplay=p{agentreplay_project_id}")
96
+
97
+ if tracestate_parts:
98
+ headers["tracestate"] = ",".join(tracestate_parts)
99
+
100
+ logger.debug(f"Injected trace context: trace_id={trace_id_hex}, span_id={span_id_hex}")
101
+
102
+
103
+ def extract_trace_context(
104
+ headers: Dict[str, str]
105
+ ) -> Tuple[Optional[int], Optional[int], int, Dict[str, str]]:
106
+ """Extract W3C Trace Context from HTTP headers.
107
+
108
+ Parses traceparent and tracestate headers following W3C specification.
109
+
110
+ Args:
111
+ headers: HTTP headers dict (case-insensitive lookup)
112
+
113
+ Returns:
114
+ Tuple of (trace_id, parent_span_id, trace_flags, tracestate_dict)
115
+ Returns (None, None, 0, {}) if no valid context found
116
+
117
+ Example:
118
+ >>> headers = {
119
+ ... "traceparent": "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"
120
+ ... }
121
+ >>> trace_id, parent_id, flags, state = extract_trace_context(headers)
122
+ >>> print(f"Trace ID: {trace_id:#x}")
123
+ """
124
+ # Make headers case-insensitive
125
+ headers_lower = {k.lower(): v for k, v in headers.items()}
126
+
127
+ # Extract traceparent
128
+ traceparent = headers_lower.get("traceparent")
129
+ if not traceparent:
130
+ logger.debug("No traceparent header found")
131
+ return (None, None, 0, {})
132
+
133
+ # Parse traceparent: version-trace_id-parent_id-trace_flags
134
+ parts = traceparent.split("-")
135
+ if len(parts) != 4:
136
+ logger.warning(f"Invalid traceparent format: {traceparent}")
137
+ return (None, None, 0, {})
138
+
139
+ version, trace_id_hex, parent_id_hex, trace_flags_hex = parts
140
+
141
+ # Validate version
142
+ if version != "00":
143
+ logger.warning(f"Unsupported traceparent version: {version}")
144
+ # Still try to parse, may be forward-compatible
145
+
146
+ try:
147
+ # Parse trace_id (128-bit)
148
+ if len(trace_id_hex) != 32:
149
+ raise ValueError(f"Invalid trace_id length: {len(trace_id_hex)}")
150
+ trace_id = int(trace_id_hex, 16)
151
+
152
+ # Parse parent_id (64-bit)
153
+ if len(parent_id_hex) != 16:
154
+ raise ValueError(f"Invalid parent_id length: {len(parent_id_hex)}")
155
+ parent_id = int(parent_id_hex, 16)
156
+
157
+ # Parse trace_flags (8-bit)
158
+ if len(trace_flags_hex) != 2:
159
+ raise ValueError(f"Invalid trace_flags length: {len(trace_flags_hex)}")
160
+ trace_flags = int(trace_flags_hex, 16)
161
+
162
+ logger.debug(f"Extracted trace context: trace_id={trace_id_hex}, parent_id={parent_id_hex}")
163
+
164
+ except ValueError as e:
165
+ logger.warning(f"Failed to parse traceparent: {e}")
166
+ return (None, None, 0, {})
167
+
168
+ # Parse tracestate (optional)
169
+ tracestate_dict = {}
170
+ tracestate = headers_lower.get("tracestate", "")
171
+ if tracestate:
172
+ # Parse comma-separated list of key=value pairs
173
+ for entry in tracestate.split(","):
174
+ entry = entry.strip()
175
+ if "=" in entry:
176
+ key, value = entry.split("=", 1)
177
+ tracestate_dict[key.strip()] = value.strip()
178
+
179
+ return (trace_id, parent_id, trace_flags, tracestate_dict)
180
+
181
+
182
+ def is_sampled(trace_flags: int) -> bool:
183
+ """Check if trace is sampled based on trace_flags.
184
+
185
+ Args:
186
+ trace_flags: 8-bit trace flags from traceparent
187
+
188
+ Returns:
189
+ True if sampled bit is set
190
+ """
191
+ return (trace_flags & 0x01) != 0
192
+
193
+
194
+ def create_trace_id() -> int:
195
+ """Generate a random 128-bit trace ID.
196
+
197
+ Returns:
198
+ Random 128-bit integer for use as trace ID
199
+ """
200
+ import random
201
+ return random.getrandbits(128)
202
+
203
+
204
+ def create_span_id() -> int:
205
+ """Generate a random 64-bit span ID.
206
+
207
+ Returns:
208
+ Random 64-bit integer for use as span ID
209
+ """
210
+ import random
211
+ return random.getrandbits(64)
212
+
213
+
214
+ class TraceContextPropagator:
215
+ """Helper class for managing trace context in client code.
216
+
217
+ Example:
218
+ >>> propagator = TraceContextPropagator()
219
+ >>>
220
+ >>> # Start a new trace
221
+ >>> trace_id, span_id = propagator.start_trace()
222
+ >>>
223
+ >>> # Make HTTP request with context
224
+ >>> headers = {}
225
+ >>> propagator.inject(headers)
226
+ >>> response = httpx.get(url, headers=headers)
227
+ >>>
228
+ >>> # Continue trace from incoming request
229
+ >>> propagator.extract(request.headers)
230
+ >>> new_span_id = propagator.create_child_span()
231
+ """
232
+
233
+ def __init__(self, tenant_id: Optional[int] = None, project_id: Optional[int] = None):
234
+ """Initialize propagator.
235
+
236
+ Args:
237
+ tenant_id: Optional tenant ID for tracestate
238
+ project_id: Optional project ID for tracestate
239
+ """
240
+ self.tenant_id = tenant_id
241
+ self.project_id = project_id
242
+ self.trace_id: Optional[int] = None
243
+ self.span_id: Optional[int] = None
244
+ self.trace_flags: int = 0x01 # Default: sampled
245
+
246
+ def start_trace(self, sampled: bool = True) -> Tuple[int, int]:
247
+ """Start a new trace.
248
+
249
+ Args:
250
+ sampled: Whether trace should be sampled
251
+
252
+ Returns:
253
+ Tuple of (trace_id, span_id)
254
+ """
255
+ self.trace_id = create_trace_id()
256
+ self.span_id = create_span_id()
257
+ self.trace_flags = 0x01 if sampled else 0x00
258
+
259
+ logger.debug(f"Started new trace: {self.trace_id:#x}")
260
+
261
+ return (self.trace_id, self.span_id)
262
+
263
+ def extract(self, headers: Dict[str, str]) -> bool:
264
+ """Extract trace context from headers.
265
+
266
+ Args:
267
+ headers: HTTP headers to extract from
268
+
269
+ Returns:
270
+ True if valid context was extracted
271
+ """
272
+ trace_id, parent_id, trace_flags, _ = extract_trace_context(headers)
273
+
274
+ if trace_id is not None:
275
+ self.trace_id = trace_id
276
+ self.span_id = parent_id
277
+ self.trace_flags = trace_flags
278
+ return True
279
+
280
+ return False
281
+
282
+ def inject(self, headers: Dict[str, str]) -> None:
283
+ """Inject trace context into headers.
284
+
285
+ Args:
286
+ headers: HTTP headers dict to inject into
287
+ """
288
+ if self.trace_id is None or self.span_id is None:
289
+ # No active trace, start a new one
290
+ self.start_trace()
291
+
292
+ inject_trace_context(
293
+ headers=headers,
294
+ trace_id=self.trace_id,
295
+ span_id=self.span_id,
296
+ trace_flags=self.trace_flags,
297
+ agentreplay_tenant_id=self.tenant_id,
298
+ agentreplay_project_id=self.project_id,
299
+ )
300
+
301
+ def create_child_span(self) -> int:
302
+ """Create a new child span ID.
303
+
304
+ Returns:
305
+ New span ID (updates internal state)
306
+ """
307
+ # Parent span ID becomes current span_id
308
+ # Generate new span ID for child
309
+ self.span_id = create_span_id()
310
+ return self.span_id
311
+
312
+ def is_sampled(self) -> bool:
313
+ """Check if current trace is sampled.
314
+
315
+ Returns:
316
+ True if sampled
317
+ """
318
+ return is_sampled(self.trace_flags)
319
+
320
+
321
+ __all__ = [
322
+ "inject_trace_context",
323
+ "extract_trace_context",
324
+ "is_sampled",
325
+ "create_trace_id",
326
+ "create_span_id",
327
+ "TraceContextPropagator",
328
+ ]
agentreplay/py.typed ADDED
@@ -0,0 +1,3 @@
1
+ # Marker file for PEP 561 - ChronoLake SDK is type-checked
2
+ # This file tells type checkers (mypy, pyright, etc.) that this package
3
+ # includes inline type hints and supports type checking.
agentreplay/retry.py ADDED
@@ -0,0 +1,151 @@
1
+ # Copyright 2025 Sushanth (https://github.com/sushanthpy)
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Retry logic with exponential backoff for resilient API calls."""
16
+
17
+ import time
18
+ import logging
19
+ from typing import Callable, TypeVar
20
+ import httpx
21
+ from agentreplay.exceptions import (
22
+ AuthenticationError,
23
+ RateLimitError,
24
+ ServerError,
25
+ ValidationError,
26
+ NotFoundError,
27
+ NetworkError,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ T = TypeVar("T")
33
+
34
+
35
+ def retry_with_backoff(
36
+ func: Callable[[], T],
37
+ max_retries: int = 3,
38
+ base_delay: float = 1.0,
39
+ max_delay: float = 60.0,
40
+ ) -> T:
41
+ """Retry a function with exponential backoff.
42
+
43
+ Automatically retries on transient failures (network errors, 5xx, 429)
44
+ but fails fast on permanent errors (4xx except 429).
45
+
46
+ Args:
47
+ func: Function to retry
48
+ max_retries: Maximum number of retry attempts (default: 3)
49
+ base_delay: Initial delay in seconds (default: 1.0)
50
+ max_delay: Maximum delay between retries (default: 60.0)
51
+
52
+ Returns:
53
+ Result of func()
54
+
55
+ Raises:
56
+ AuthenticationError: On 401 errors (no retry)
57
+ ValidationError: On 400 errors (no retry)
58
+ NotFoundError: On 404 errors (no retry)
59
+ RateLimitError: On 429 after max retries
60
+ ServerError: On 5xx after max retries
61
+ NetworkError: On network errors after max retries
62
+
63
+ Example:
64
+ >>> def make_request():
65
+ ... return client.post("/api/v1/edges", json={...})
66
+ >>>
67
+ >>> response = retry_with_backoff(make_request, max_retries=5)
68
+ """
69
+ last_exception = None
70
+
71
+ for attempt in range(max_retries + 1):
72
+ try:
73
+ return func()
74
+
75
+ except httpx.HTTPStatusError as e:
76
+ status_code = e.response.status_code
77
+
78
+ # 401 - Authentication error (no retry)
79
+ if status_code == 401:
80
+ logger.error(f"Authentication failed: {e}")
81
+ raise AuthenticationError(str(e))
82
+
83
+ # 400 - Validation error (no retry)
84
+ elif status_code == 400:
85
+ logger.error(f"Validation error: {e}")
86
+ raise ValidationError(e.response.text)
87
+
88
+ # 404 - Not found (no retry)
89
+ elif status_code == 404:
90
+ logger.error(f"Resource not found: {e}")
91
+ raise NotFoundError(e.request.url.path)
92
+
93
+ # 429 - Rate limited (retry with server-specified delay)
94
+ elif status_code == 429:
95
+ retry_after_float = float(e.response.headers.get("Retry-After", base_delay * (2**attempt)))
96
+ retry_after_float = min(retry_after_float, max_delay)
97
+ retry_after = int(retry_after_float)
98
+
99
+ if attempt < max_retries:
100
+ logger.warning(
101
+ f"Rate limited (attempt {attempt + 1}/{max_retries}). "
102
+ f"Retrying after {retry_after}s"
103
+ )
104
+ time.sleep(retry_after)
105
+ last_exception = RateLimitError(retry_after)
106
+ continue
107
+ else:
108
+ logger.error(f"Rate limited after {max_retries} retries")
109
+ raise RateLimitError(retry_after)
110
+
111
+ # 5xx - Server error (retry with exponential backoff)
112
+ elif status_code >= 500:
113
+ delay = min(base_delay * (2**attempt), max_delay)
114
+
115
+ if attempt < max_retries:
116
+ logger.warning(
117
+ f"Server error {status_code} (attempt {attempt + 1}/{max_retries}). "
118
+ f"Retrying after {delay}s"
119
+ )
120
+ time.sleep(delay)
121
+ last_exception = ServerError(status_code, e.response.text)
122
+ continue
123
+ else:
124
+ logger.error(f"Server error after {max_retries} retries: {e}")
125
+ raise ServerError(status_code, e.response.text)
126
+
127
+ # Other 4xx errors - no retry
128
+ else:
129
+ logger.error(f"HTTP error {status_code}: {e}")
130
+ raise ValidationError(f"HTTP {status_code}: {e.response.text}")
131
+
132
+ except (httpx.RequestError, httpx.ConnectError, httpx.TimeoutException) as e:
133
+ # Network errors - retry with exponential backoff
134
+ delay = min(base_delay * (2**attempt), max_delay)
135
+
136
+ if attempt < max_retries:
137
+ logger.warning(
138
+ f"Network error (attempt {attempt + 1}/{max_retries}): {e}. "
139
+ f"Retrying after {delay}s"
140
+ )
141
+ time.sleep(delay)
142
+ last_exception = NetworkError(str(e))
143
+ continue
144
+ else:
145
+ logger.error(f"Network error after {max_retries} retries: {e}")
146
+ raise NetworkError(str(e))
147
+
148
+ # Should never reach here, but handle gracefully
149
+ if last_exception:
150
+ raise last_exception
151
+ raise NetworkError("Unknown error after retries")