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.
- agentreplay/__init__.py +81 -0
- agentreplay/auto_instrument/__init__.py +237 -0
- agentreplay/auto_instrument/openai.py +431 -0
- agentreplay/batching.py +270 -0
- agentreplay/bootstrap.py +202 -0
- agentreplay/circuit_breaker.py +300 -0
- agentreplay/client.py +1560 -0
- agentreplay/config.py +215 -0
- agentreplay/context.py +168 -0
- agentreplay/env_config.py +327 -0
- agentreplay/env_init.py +128 -0
- agentreplay/exceptions.py +92 -0
- agentreplay/genai.py +510 -0
- agentreplay/genai_conventions.py +502 -0
- agentreplay/install_pth.py +159 -0
- agentreplay/langchain_tracer.py +385 -0
- agentreplay/models.py +120 -0
- agentreplay/otel_bridge.py +281 -0
- agentreplay/patch.py +308 -0
- agentreplay/propagation.py +328 -0
- agentreplay/py.typed +3 -0
- agentreplay/retry.py +151 -0
- agentreplay/sampling.py +298 -0
- agentreplay/session.py +164 -0
- agentreplay/sitecustomize.py +73 -0
- agentreplay/span.py +270 -0
- agentreplay/unified.py +465 -0
- agentreplay-0.1.2.dist-info/METADATA +285 -0
- agentreplay-0.1.2.dist-info/RECORD +33 -0
- agentreplay-0.1.2.dist-info/WHEEL +5 -0
- agentreplay-0.1.2.dist-info/entry_points.txt +2 -0
- agentreplay-0.1.2.dist-info/licenses/LICENSE +190 -0
- agentreplay-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -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
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")
|