reflectapi-runtime 0.1.0__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.
- reflectapi_runtime/__init__.py +109 -0
- reflectapi_runtime/auth.py +507 -0
- reflectapi_runtime/batch.py +165 -0
- reflectapi_runtime/client.py +924 -0
- reflectapi_runtime/exceptions.py +120 -0
- reflectapi_runtime/hypothesis_strategies.py +275 -0
- reflectapi_runtime/middleware.py +254 -0
- reflectapi_runtime/option.py +295 -0
- reflectapi_runtime/response.py +126 -0
- reflectapi_runtime/streaming.py +435 -0
- reflectapi_runtime/testing.py +380 -0
- reflectapi_runtime/types.py +32 -0
- reflectapi_runtime-0.1.0.dist-info/METADATA +36 -0
- reflectapi_runtime-0.1.0.dist-info/RECORD +15 -0
- reflectapi_runtime-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Exception hierarchy for ReflectAPI Python clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx # noqa: TC002
|
|
8
|
+
|
|
9
|
+
from .response import TransportMetadata # noqa: TC001
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ApiError(Exception):
|
|
13
|
+
"""Base class for all API-related errors.
|
|
14
|
+
|
|
15
|
+
Always contains transport metadata when available, providing access to
|
|
16
|
+
status codes, headers, timing, and the raw response.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
metadata: TransportMetadata | None = None,
|
|
24
|
+
cause: Exception | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.metadata = metadata
|
|
28
|
+
self.cause = cause
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def status_code(self) -> int | None:
|
|
32
|
+
"""HTTP status code if available."""
|
|
33
|
+
return self.metadata.status_code if self.metadata else None
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
parts = [f"message={self.args[0]!r}"]
|
|
37
|
+
if self.metadata:
|
|
38
|
+
parts.append(f"status_code={self.metadata.status_code}")
|
|
39
|
+
if self.cause:
|
|
40
|
+
parts.append(f"cause={self.cause!r}")
|
|
41
|
+
return f"{self.__class__.__name__}({', '.join(parts)})"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NetworkError(ApiError):
|
|
45
|
+
"""Network-level errors (connection failures, DNS resolution, etc.)."""
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_httpx_error(cls, error: httpx.RequestError) -> NetworkError:
|
|
49
|
+
"""Create a NetworkError from an httpx RequestError."""
|
|
50
|
+
return cls(
|
|
51
|
+
f"Network error: {error}",
|
|
52
|
+
cause=error,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TimeoutError(NetworkError):
|
|
57
|
+
"""Request timeout errors."""
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_httpx_timeout(cls, error: httpx.TimeoutException) -> TimeoutError:
|
|
61
|
+
"""Create a TimeoutError from an httpx TimeoutException."""
|
|
62
|
+
return cls(
|
|
63
|
+
f"Request timed out: {error}",
|
|
64
|
+
cause=error,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ApplicationError(ApiError):
|
|
69
|
+
"""Application-level errors (4xx and 5xx HTTP responses).
|
|
70
|
+
|
|
71
|
+
These represent errors returned by the API server, as opposed to
|
|
72
|
+
network or client-side validation errors.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
message: str,
|
|
78
|
+
*,
|
|
79
|
+
metadata: TransportMetadata,
|
|
80
|
+
error_data: Any | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
super().__init__(message, metadata=metadata)
|
|
83
|
+
self.error_data = error_data
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def status_code(self) -> int:
|
|
87
|
+
"""Get the HTTP status code from metadata."""
|
|
88
|
+
return self.metadata.status_code if self.metadata else 0
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_response(
|
|
92
|
+
cls,
|
|
93
|
+
response: httpx.Response,
|
|
94
|
+
metadata: TransportMetadata,
|
|
95
|
+
error_data: Any | None = None,
|
|
96
|
+
) -> ApplicationError:
|
|
97
|
+
"""Create an ApplicationError from an HTTP response."""
|
|
98
|
+
message = f"API error {response.status_code}: {response.reason_phrase}"
|
|
99
|
+
if error_data:
|
|
100
|
+
message += f" - {error_data}"
|
|
101
|
+
|
|
102
|
+
return cls(
|
|
103
|
+
message,
|
|
104
|
+
metadata=metadata,
|
|
105
|
+
error_data=error_data,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ValidationError(ApiError):
|
|
110
|
+
"""Client-side validation errors (malformed requests, invalid data, etc.)."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
message: str,
|
|
115
|
+
*,
|
|
116
|
+
validation_errors: list[Any] | None = None,
|
|
117
|
+
cause: Exception | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
super().__init__(message, cause=cause)
|
|
120
|
+
self.validation_errors = validation_errors or []
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Hypothesis strategies for generated Pydantic models.
|
|
2
|
+
|
|
3
|
+
This module provides utilities to automatically generate Hypothesis strategies
|
|
4
|
+
for ReflectAPI-generated Pydantic models, enabling property-based testing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any, Union, get_args, get_origin
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import hypothesis
|
|
15
|
+
from hypothesis import strategies as st
|
|
16
|
+
HAS_HYPOTHESIS = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
HAS_HYPOTHESIS = False
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
from .option import ReflectapiOption, Undefined
|
|
23
|
+
|
|
24
|
+
if HAS_HYPOTHESIS:
|
|
25
|
+
|
|
26
|
+
def strategy_for_option(inner_strategy: st.SearchStrategy) -> st.SearchStrategy:
|
|
27
|
+
"""Create a strategy for ReflectapiOption types.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
inner_strategy: Strategy for the inner type T in Option<T>.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Strategy that produces ReflectapiOption instances with undefined, None, or values.
|
|
34
|
+
"""
|
|
35
|
+
return st.one_of(
|
|
36
|
+
st.just(ReflectapiOption(Undefined)), # Undefined case
|
|
37
|
+
st.just(ReflectapiOption(None)), # None case
|
|
38
|
+
inner_strategy.map(ReflectapiOption), # Some(value) case
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def strategy_for_type(type_hint: type) -> st.SearchStrategy:
|
|
43
|
+
"""Generate a Hypothesis strategy for a given type hint.
|
|
44
|
+
|
|
45
|
+
This function maps Python types to appropriate Hypothesis strategies,
|
|
46
|
+
with special handling for ReflectAPI types.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
type_hint: The type to generate a strategy for.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Hypothesis strategy for the given type.
|
|
53
|
+
"""
|
|
54
|
+
# Handle basic types
|
|
55
|
+
if type_hint is str:
|
|
56
|
+
return st.text()
|
|
57
|
+
elif type_hint is int:
|
|
58
|
+
return st.integers()
|
|
59
|
+
elif type_hint is float:
|
|
60
|
+
return st.floats(allow_nan=False, allow_infinity=False)
|
|
61
|
+
elif type_hint is bool:
|
|
62
|
+
return st.booleans()
|
|
63
|
+
elif type_hint is bytes:
|
|
64
|
+
return st.binary()
|
|
65
|
+
elif type_hint is datetime.datetime:
|
|
66
|
+
return st.datetimes()
|
|
67
|
+
elif type_hint is datetime.date:
|
|
68
|
+
return st.dates()
|
|
69
|
+
elif type_hint is uuid.UUID:
|
|
70
|
+
return st.uuids()
|
|
71
|
+
|
|
72
|
+
# Handle ReflectapiOption specifically
|
|
73
|
+
if hasattr(type_hint, '__origin__') and type_hint.__origin__ is ReflectapiOption:
|
|
74
|
+
inner_type = get_args(type_hint)[0] if get_args(type_hint) else Any
|
|
75
|
+
inner_strategy = strategy_for_type(inner_type)
|
|
76
|
+
return strategy_for_option(inner_strategy)
|
|
77
|
+
|
|
78
|
+
# Handle generic types
|
|
79
|
+
origin = get_origin(type_hint)
|
|
80
|
+
args = get_args(type_hint)
|
|
81
|
+
|
|
82
|
+
if origin is Union:
|
|
83
|
+
# Handle Optional[T] (Union[T, None]) and other unions
|
|
84
|
+
if len(args) == 2 and type(None) in args:
|
|
85
|
+
# Optional type
|
|
86
|
+
non_none_type = args[0] if args[1] is type(None) else args[1]
|
|
87
|
+
return st.one_of(st.none(), strategy_for_type(non_none_type))
|
|
88
|
+
else:
|
|
89
|
+
# General union
|
|
90
|
+
return st.one_of(*[strategy_for_type(arg) for arg in args])
|
|
91
|
+
|
|
92
|
+
elif origin is list:
|
|
93
|
+
inner_type = args[0] if args else Any
|
|
94
|
+
return st.lists(strategy_for_type(inner_type), max_size=10)
|
|
95
|
+
|
|
96
|
+
elif origin is dict:
|
|
97
|
+
key_type = args[0] if len(args) > 0 else str
|
|
98
|
+
value_type = args[1] if len(args) > 1 else Any
|
|
99
|
+
return st.dictionaries(
|
|
100
|
+
strategy_for_type(key_type),
|
|
101
|
+
strategy_for_type(value_type),
|
|
102
|
+
max_size=10
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
elif origin is tuple:
|
|
106
|
+
if args:
|
|
107
|
+
return st.tuples(*[strategy_for_type(arg) for arg in args])
|
|
108
|
+
else:
|
|
109
|
+
return st.tuples()
|
|
110
|
+
|
|
111
|
+
# Handle Pydantic models
|
|
112
|
+
if isinstance(type_hint, type) and issubclass(type_hint, BaseModel):
|
|
113
|
+
return strategy_for_pydantic_model(type_hint)
|
|
114
|
+
|
|
115
|
+
# Fallback for unknown types
|
|
116
|
+
return st.none()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def strategy_for_pydantic_model(model_class: type[BaseModel]) -> st.SearchStrategy:
|
|
120
|
+
"""Generate a Hypothesis strategy for a Pydantic model.
|
|
121
|
+
|
|
122
|
+
This creates a strategy that generates valid instances of the given
|
|
123
|
+
Pydantic model class, respecting field types and constraints.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
model_class: The Pydantic model class to generate strategies for.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Strategy that produces instances of the model class.
|
|
130
|
+
"""
|
|
131
|
+
field_strategies = {}
|
|
132
|
+
|
|
133
|
+
# Handle Pydantic models
|
|
134
|
+
if hasattr(model_class, 'model_fields'):
|
|
135
|
+
for field_name, field_info in model_class.model_fields.items():
|
|
136
|
+
field_type = field_info.annotation
|
|
137
|
+
field_strategies[field_name] = strategy_for_type(field_type)
|
|
138
|
+
else:
|
|
139
|
+
raise ValueError(f"Unsupported Pydantic model: {model_class}. Only Pydantic V2 models are supported.")
|
|
140
|
+
|
|
141
|
+
# Create a strategy that builds the model
|
|
142
|
+
return st.builds(model_class, **field_strategies)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def register_custom_strategy(type_hint: type, strategy: st.SearchStrategy) -> None:
|
|
146
|
+
"""Register a custom strategy for a specific type.
|
|
147
|
+
|
|
148
|
+
This allows users to override the default strategy generation
|
|
149
|
+
for specific types or add support for custom types.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
type_hint: The type to register a strategy for.
|
|
153
|
+
strategy: The Hypothesis strategy to use for this type.
|
|
154
|
+
"""
|
|
155
|
+
if not hasattr(register_custom_strategy, '_custom_strategies'):
|
|
156
|
+
register_custom_strategy._custom_strategies = {}
|
|
157
|
+
register_custom_strategy._custom_strategies[type_hint] = strategy
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_custom_strategy(type_hint: type) -> st.SearchStrategy | None:
|
|
161
|
+
"""Get a custom strategy for a type, if registered.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
type_hint: The type to look up.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Custom strategy if registered, None otherwise.
|
|
168
|
+
"""
|
|
169
|
+
if hasattr(register_custom_strategy, '_custom_strategies'):
|
|
170
|
+
return register_custom_strategy._custom_strategies.get(type_hint)
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def enhanced_strategy_for_type(type_hint: type) -> st.SearchStrategy:
|
|
175
|
+
"""Enhanced strategy generation with custom strategy support.
|
|
176
|
+
|
|
177
|
+
This is the main entry point that checks for custom strategies
|
|
178
|
+
before falling back to the default generation logic.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
type_hint: The type to generate a strategy for.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Hypothesis strategy for the given type.
|
|
185
|
+
"""
|
|
186
|
+
# Check for custom strategies first
|
|
187
|
+
custom_strategy = get_custom_strategy(type_hint)
|
|
188
|
+
if custom_strategy is not None:
|
|
189
|
+
return custom_strategy
|
|
190
|
+
|
|
191
|
+
# Fall back to default logic
|
|
192
|
+
return strategy_for_type(type_hint)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Convenience function for common patterns
|
|
196
|
+
def api_model_strategy(
|
|
197
|
+
model_class: type[BaseModel],
|
|
198
|
+
**field_overrides: st.SearchStrategy
|
|
199
|
+
) -> st.SearchStrategy:
|
|
200
|
+
"""Create a strategy for an API model with field overrides.
|
|
201
|
+
|
|
202
|
+
This is useful when you want to use the default strategy for most fields
|
|
203
|
+
but customize specific fields for testing scenarios.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
model_class: The Pydantic model class.
|
|
207
|
+
**field_overrides: Custom strategies for specific fields.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Strategy for the model with custom field strategies applied.
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
```python
|
|
214
|
+
# Override the 'id' field to use positive integers
|
|
215
|
+
strategy = api_model_strategy(
|
|
216
|
+
UserModel,
|
|
217
|
+
id=st.integers(min_value=1),
|
|
218
|
+
email=st.emails()
|
|
219
|
+
)
|
|
220
|
+
```
|
|
221
|
+
"""
|
|
222
|
+
field_strategies = {}
|
|
223
|
+
|
|
224
|
+
# Get default strategies for all fields
|
|
225
|
+
if hasattr(model_class, 'model_fields'):
|
|
226
|
+
# Pydantic models
|
|
227
|
+
for field_name, field_info in model_class.model_fields.items():
|
|
228
|
+
if field_name in field_overrides:
|
|
229
|
+
field_strategies[field_name] = field_overrides[field_name]
|
|
230
|
+
else:
|
|
231
|
+
field_strategies[field_name] = enhanced_strategy_for_type(field_info.annotation)
|
|
232
|
+
else:
|
|
233
|
+
raise ValueError(f"Unsupported Pydantic model: {model_class}. Only Pydantic V2 models are supported.")
|
|
234
|
+
|
|
235
|
+
return st.builds(model_class, **field_strategies)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
else:
|
|
239
|
+
# Hypothesis not available - provide stub implementations
|
|
240
|
+
|
|
241
|
+
def strategy_for_option(inner_strategy):
|
|
242
|
+
"""Stub implementation when Hypothesis is not available."""
|
|
243
|
+
raise ImportError("Hypothesis is required for strategy generation")
|
|
244
|
+
|
|
245
|
+
def strategy_for_type(type_hint):
|
|
246
|
+
"""Stub implementation when Hypothesis is not available."""
|
|
247
|
+
raise ImportError("Hypothesis is required for strategy generation")
|
|
248
|
+
|
|
249
|
+
def strategy_for_pydantic_model(model_class):
|
|
250
|
+
"""Stub implementation when Hypothesis is not available."""
|
|
251
|
+
raise ImportError("Hypothesis is required for strategy generation")
|
|
252
|
+
|
|
253
|
+
def register_custom_strategy(type_hint, strategy):
|
|
254
|
+
"""Stub implementation when Hypothesis is not available."""
|
|
255
|
+
raise ImportError("Hypothesis is required for strategy generation")
|
|
256
|
+
|
|
257
|
+
def enhanced_strategy_for_type(type_hint):
|
|
258
|
+
"""Stub implementation when Hypothesis is not available."""
|
|
259
|
+
raise ImportError("Hypothesis is required for strategy generation")
|
|
260
|
+
|
|
261
|
+
def api_model_strategy(model_class, **field_overrides):
|
|
262
|
+
"""Stub implementation when Hypothesis is not available."""
|
|
263
|
+
raise ImportError("Hypothesis is required for strategy generation")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# Export the main functions
|
|
267
|
+
__all__ = [
|
|
268
|
+
'HAS_HYPOTHESIS',
|
|
269
|
+
'strategy_for_option',
|
|
270
|
+
'strategy_for_type',
|
|
271
|
+
'strategy_for_pydantic_model',
|
|
272
|
+
'enhanced_strategy_for_type',
|
|
273
|
+
'register_custom_strategy',
|
|
274
|
+
'api_model_strategy',
|
|
275
|
+
]
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Middleware system for ReflectAPI Python clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import random
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Type aliases for handlers in the middleware chain
|
|
17
|
+
AsyncNextHandler = Callable[[httpx.Request], Awaitable[httpx.Response]]
|
|
18
|
+
SyncNextHandler = Callable[[httpx.Request], httpx.Response]
|
|
19
|
+
NextHandler = Union[AsyncNextHandler, SyncNextHandler]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncMiddleware(ABC):
|
|
23
|
+
"""Base class for asynchronous client middleware.
|
|
24
|
+
|
|
25
|
+
Middleware can intercept and transform requests and responses,
|
|
26
|
+
enabling cross-cutting concerns like caching, logging, retry logic, etc.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def handle(
|
|
31
|
+
self, request: httpx.Request, next_call: AsyncNextHandler
|
|
32
|
+
) -> httpx.Response:
|
|
33
|
+
"""Handle a request and return a response.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
request: The HTTP request to process
|
|
37
|
+
next_call: Async callable to continue the middleware chain
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The HTTP response (possibly modified)
|
|
41
|
+
"""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SyncMiddleware(ABC):
|
|
46
|
+
"""Base class for synchronous client middleware.
|
|
47
|
+
|
|
48
|
+
Middleware can intercept and transform requests and responses,
|
|
49
|
+
enabling cross-cutting concerns like caching, logging, retry logic, etc.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def handle(
|
|
54
|
+
self, request: httpx.Request, next_call: SyncNextHandler
|
|
55
|
+
) -> httpx.Response:
|
|
56
|
+
"""Handle a request and return a response.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
request: The HTTP request to process
|
|
60
|
+
next_call: Callable to continue the middleware chain
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The HTTP response (possibly modified)
|
|
64
|
+
"""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncLoggingMiddleware(AsyncMiddleware):
|
|
69
|
+
"""Async middleware that logs request and response information."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, logger_name: str = "reflectapi.client") -> None:
|
|
72
|
+
self.logger = logging.getLogger(logger_name)
|
|
73
|
+
|
|
74
|
+
async def handle(
|
|
75
|
+
self, request: httpx.Request, next_call: AsyncNextHandler
|
|
76
|
+
) -> httpx.Response:
|
|
77
|
+
"""Log request and response details."""
|
|
78
|
+
self.logger.debug(
|
|
79
|
+
"Making request",
|
|
80
|
+
extra={
|
|
81
|
+
"method": request.method,
|
|
82
|
+
"url": str(request.url),
|
|
83
|
+
"headers": dict(request.headers),
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
response = await next_call(request)
|
|
88
|
+
|
|
89
|
+
self.logger.debug(
|
|
90
|
+
"Received response",
|
|
91
|
+
extra={
|
|
92
|
+
"status_code": response.status_code,
|
|
93
|
+
"headers": dict(response.headers),
|
|
94
|
+
"url": str(request.url),
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return response
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SyncLoggingMiddleware(SyncMiddleware):
|
|
102
|
+
"""Sync middleware that logs request and response information."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, logger_name: str = "reflectapi.client") -> None:
|
|
105
|
+
self.logger = logging.getLogger(logger_name)
|
|
106
|
+
|
|
107
|
+
def handle(
|
|
108
|
+
self, request: httpx.Request, next_call: SyncNextHandler
|
|
109
|
+
) -> httpx.Response:
|
|
110
|
+
"""Log request and response details."""
|
|
111
|
+
self.logger.debug(
|
|
112
|
+
"Making request",
|
|
113
|
+
extra={
|
|
114
|
+
"method": request.method,
|
|
115
|
+
"url": str(request.url),
|
|
116
|
+
"headers": dict(request.headers),
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
response = next_call(request)
|
|
121
|
+
|
|
122
|
+
self.logger.debug(
|
|
123
|
+
"Received response",
|
|
124
|
+
extra={
|
|
125
|
+
"status_code": response.status_code,
|
|
126
|
+
"headers": dict(response.headers),
|
|
127
|
+
"url": str(request.url),
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return response
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class RetryMiddleware(AsyncMiddleware):
|
|
135
|
+
"""Middleware that retries requests on transient failures with exponential backoff and jitter."""
|
|
136
|
+
|
|
137
|
+
# Methods that are safe to retry automatically on network failures
|
|
138
|
+
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"})
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
max_retries: int = 3,
|
|
143
|
+
retry_status_codes: set[int] | None = None,
|
|
144
|
+
backoff_factor: float = 0.5, # Start with a shorter backoff
|
|
145
|
+
) -> None:
|
|
146
|
+
self.max_retries = max_retries
|
|
147
|
+
self.retry_status_codes = retry_status_codes or {429, 502, 503, 504}
|
|
148
|
+
self.backoff_factor = backoff_factor
|
|
149
|
+
|
|
150
|
+
async def handle(
|
|
151
|
+
self, request: httpx.Request, next_call: AsyncNextHandler
|
|
152
|
+
) -> httpx.Response:
|
|
153
|
+
"""Retry requests on transient failures."""
|
|
154
|
+
last_exception = None
|
|
155
|
+
|
|
156
|
+
for attempt in range(self.max_retries + 1):
|
|
157
|
+
try:
|
|
158
|
+
response = await next_call(request)
|
|
159
|
+
|
|
160
|
+
if attempt == self.max_retries or response.status_code not in self.retry_status_codes:
|
|
161
|
+
return response
|
|
162
|
+
|
|
163
|
+
# If we are going to retry, store the response as the last exception
|
|
164
|
+
last_exception = response
|
|
165
|
+
|
|
166
|
+
except httpx.RequestError as e:
|
|
167
|
+
last_exception = e
|
|
168
|
+
# Do not retry non-idempotent methods on network errors
|
|
169
|
+
if request.method not in self.IDEMPOTENT_METHODS or attempt == self.max_retries:
|
|
170
|
+
raise
|
|
171
|
+
|
|
172
|
+
# Backoff with Jitter (AWS-recommended approach)
|
|
173
|
+
# Cap the backoff at 30 seconds and add jitter for better distribution
|
|
174
|
+
temp = min(self.backoff_factor * (2 ** attempt), 30.0) # Cap backoff
|
|
175
|
+
sleep_duration = temp / 2 + random.uniform(0, temp / 2)
|
|
176
|
+
|
|
177
|
+
logger.debug(
|
|
178
|
+
f"Retrying request to {request.url} after {sleep_duration:.2f}s (attempt {attempt + 1}/{self.max_retries})"
|
|
179
|
+
)
|
|
180
|
+
await asyncio.sleep(sleep_duration)
|
|
181
|
+
|
|
182
|
+
# This part should be unreachable, but linters might complain.
|
|
183
|
+
# It's better to re-raise the last known exception.
|
|
184
|
+
if isinstance(last_exception, httpx.Response):
|
|
185
|
+
return last_exception
|
|
186
|
+
raise last_exception from None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class AsyncMiddlewareChain:
|
|
190
|
+
"""Manages a chain of async middleware for processing requests."""
|
|
191
|
+
|
|
192
|
+
def __init__(self, middleware: list[AsyncMiddleware]) -> None:
|
|
193
|
+
self.middleware = middleware
|
|
194
|
+
|
|
195
|
+
async def execute(
|
|
196
|
+
self,
|
|
197
|
+
request: httpx.Request,
|
|
198
|
+
transport: httpx.AsyncClient,
|
|
199
|
+
) -> httpx.Response:
|
|
200
|
+
"""Execute the middleware chain with the given request."""
|
|
201
|
+
|
|
202
|
+
async def create_handler(
|
|
203
|
+
middleware_list: list[AsyncMiddleware], index: int
|
|
204
|
+
) -> AsyncNextHandler:
|
|
205
|
+
"""Create a handler for the middleware at the given index."""
|
|
206
|
+
|
|
207
|
+
async def handler(req: httpx.Request) -> httpx.Response:
|
|
208
|
+
if index >= len(middleware_list):
|
|
209
|
+
# End of chain - make the actual HTTP request
|
|
210
|
+
return await transport.send(req)
|
|
211
|
+
|
|
212
|
+
# Process through next middleware
|
|
213
|
+
next_handler = await create_handler(middleware_list, index + 1)
|
|
214
|
+
return await middleware_list[index].handle(req, next_handler)
|
|
215
|
+
|
|
216
|
+
return handler
|
|
217
|
+
|
|
218
|
+
handler = await create_handler(self.middleware, 0)
|
|
219
|
+
return await handler(request)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class SyncMiddlewareChain:
|
|
223
|
+
"""Manages a chain of sync middleware for processing requests."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, middleware: list[SyncMiddleware]) -> None:
|
|
226
|
+
self.middleware = middleware
|
|
227
|
+
|
|
228
|
+
def execute(
|
|
229
|
+
self,
|
|
230
|
+
request: httpx.Request,
|
|
231
|
+
transport: httpx.Client,
|
|
232
|
+
) -> httpx.Response:
|
|
233
|
+
"""Execute the middleware chain with the given request."""
|
|
234
|
+
|
|
235
|
+
def create_handler(
|
|
236
|
+
middleware_list: list[SyncMiddleware], index: int
|
|
237
|
+
) -> SyncNextHandler:
|
|
238
|
+
"""Create a handler for the middleware at the given index."""
|
|
239
|
+
|
|
240
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
241
|
+
if index >= len(middleware_list):
|
|
242
|
+
# End of chain - make the actual HTTP request
|
|
243
|
+
return transport.send(req)
|
|
244
|
+
|
|
245
|
+
# Process through next middleware
|
|
246
|
+
next_handler = create_handler(middleware_list, index + 1)
|
|
247
|
+
return middleware_list[index].handle(req, next_handler)
|
|
248
|
+
|
|
249
|
+
return handler
|
|
250
|
+
|
|
251
|
+
handler = create_handler(self.middleware, 0)
|
|
252
|
+
return handler(request)
|
|
253
|
+
|
|
254
|
+
|