airbyte-agent-mailchimp 0.1.4__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.
- airbyte_agent_mailchimp/__init__.py +217 -0
- airbyte_agent_mailchimp/_vendored/__init__.py +1 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/__init__.py +82 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/auth_strategies.py +1120 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/auth_template.py +135 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/cloud_utils/__init__.py +5 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/cloud_utils/client.py +213 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/connector_model_loader.py +965 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/constants.py +78 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/exceptions.py +23 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/executor/__init__.py +31 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/executor/hosted_executor.py +196 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/executor/local_executor.py +1641 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/executor/models.py +190 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/extensions.py +693 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/http/__init__.py +37 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/http/config.py +98 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/http/exceptions.py +119 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/http/protocols.py +114 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/http/response.py +104 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/http_client.py +686 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/introspection.py +262 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/logging/__init__.py +11 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/logging/logger.py +264 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/logging/types.py +92 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/observability/__init__.py +11 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/observability/config.py +179 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/observability/models.py +19 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/observability/redactor.py +81 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/observability/session.py +103 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/performance/__init__.py +6 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/performance/instrumentation.py +57 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/performance/metrics.py +93 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/schema/__init__.py +75 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/schema/base.py +164 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/schema/components.py +239 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/schema/connector.py +120 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/schema/extensions.py +230 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/schema/operations.py +146 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/schema/security.py +223 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/secrets.py +182 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/telemetry/__init__.py +10 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/telemetry/config.py +32 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/telemetry/events.py +59 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/telemetry/tracker.py +155 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/types.py +245 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/utils.py +60 -0
- airbyte_agent_mailchimp/_vendored/connector_sdk/validation.py +822 -0
- airbyte_agent_mailchimp/connector.py +1378 -0
- airbyte_agent_mailchimp/connector_model.py +4749 -0
- airbyte_agent_mailchimp/models.py +956 -0
- airbyte_agent_mailchimp/types.py +164 -0
- airbyte_agent_mailchimp-0.1.4.dist-info/METADATA +119 -0
- airbyte_agent_mailchimp-0.1.4.dist-info/RECORD +57 -0
- airbyte_agent_mailchimp-0.1.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
"""Async HTTP client with connection pooling, auth injection, metrics, and retry support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .constants import (
|
|
14
|
+
DEFAULT_CONNECT_TIMEOUT,
|
|
15
|
+
DEFAULT_MAX_CONNECTIONS,
|
|
16
|
+
DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
|
|
17
|
+
DEFAULT_REQUEST_TIMEOUT,
|
|
18
|
+
)
|
|
19
|
+
from .http import (
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
ClientConfig,
|
|
22
|
+
ConnectionLimits,
|
|
23
|
+
HTTPClientError,
|
|
24
|
+
HTTPClientProtocol,
|
|
25
|
+
HTTPStatusError,
|
|
26
|
+
NetworkError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
TimeoutConfig,
|
|
29
|
+
TimeoutError,
|
|
30
|
+
)
|
|
31
|
+
from .http.adapters import HTTPXClient
|
|
32
|
+
from .schema.extensions import RetryConfig
|
|
33
|
+
from .secrets import SecretStr
|
|
34
|
+
|
|
35
|
+
from .auth_strategies import AuthStrategyFactory
|
|
36
|
+
from .logging import NullLogger
|
|
37
|
+
from .types import AuthConfig, AuthType
|
|
38
|
+
|
|
39
|
+
# Type alias for token refresh callback
|
|
40
|
+
# Supports both sync and async callbacks for flexibility
|
|
41
|
+
TokenRefreshCallback = Callable[[dict[str, str]], None] | Callable[[dict[str, str]], Awaitable[None]] | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HTTPMetrics:
|
|
45
|
+
"""Metrics collector for HTTP requests."""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
"""Initialize metrics."""
|
|
49
|
+
self.request_count = 0
|
|
50
|
+
self.error_count = 0
|
|
51
|
+
self.total_duration = 0.0
|
|
52
|
+
self.status_counts: dict[int, int] = defaultdict(int)
|
|
53
|
+
# Retry metrics
|
|
54
|
+
self.retry_count = 0
|
|
55
|
+
self.total_retry_delay = 0.0
|
|
56
|
+
|
|
57
|
+
def record_request(self, duration: float, status_code: int, success: bool):
|
|
58
|
+
"""Record a request metric.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
duration: Request duration in seconds
|
|
62
|
+
status_code: HTTP status code
|
|
63
|
+
success: Whether the request succeeded
|
|
64
|
+
"""
|
|
65
|
+
self.request_count += 1
|
|
66
|
+
self.total_duration += duration
|
|
67
|
+
self.status_counts[status_code] += 1
|
|
68
|
+
if not success:
|
|
69
|
+
self.error_count += 1
|
|
70
|
+
|
|
71
|
+
def record_retry(self, delay: float):
|
|
72
|
+
"""Record a retry attempt.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
delay: Delay in seconds before the retry
|
|
76
|
+
"""
|
|
77
|
+
self.retry_count += 1
|
|
78
|
+
self.total_retry_delay += delay
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def avg_duration(self) -> float:
|
|
82
|
+
"""Get average request duration."""
|
|
83
|
+
if self.request_count == 0:
|
|
84
|
+
return 0.0
|
|
85
|
+
return self.total_duration / self.request_count
|
|
86
|
+
|
|
87
|
+
def get_stats(self) -> dict[str, Any]:
|
|
88
|
+
"""Get metrics as dictionary."""
|
|
89
|
+
return {
|
|
90
|
+
"request_count": self.request_count,
|
|
91
|
+
"error_count": self.error_count,
|
|
92
|
+
"avg_duration": self.avg_duration,
|
|
93
|
+
"total_duration": self.total_duration,
|
|
94
|
+
"status_counts": dict(self.status_counts),
|
|
95
|
+
"retry_count": self.retry_count,
|
|
96
|
+
"total_retry_delay": self.total_retry_delay,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class HTTPClient:
|
|
101
|
+
"""Async HTTP client for making API requests with authentication and connection pooling."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
base_url: str,
|
|
106
|
+
auth_config: AuthConfig,
|
|
107
|
+
secrets: dict[str, SecretStr | str],
|
|
108
|
+
config_values: dict[str, str] | None = None,
|
|
109
|
+
client: HTTPClientProtocol | None = None,
|
|
110
|
+
logger: Any | None = None,
|
|
111
|
+
max_connections: int = DEFAULT_MAX_CONNECTIONS,
|
|
112
|
+
max_keepalive_connections: int = DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
|
|
113
|
+
timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
114
|
+
connect_timeout: float | None = None,
|
|
115
|
+
read_timeout: float | None = None,
|
|
116
|
+
on_token_refresh: TokenRefreshCallback = None,
|
|
117
|
+
retry_config: RetryConfig | None = None,
|
|
118
|
+
):
|
|
119
|
+
"""Initialize async HTTP client.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
base_url: Base URL for API (e.g., https://api.stripe.com)
|
|
123
|
+
auth_config: Authentication configuration from connector.yaml
|
|
124
|
+
secrets: Secret credentials (SecretStr or plain str values)
|
|
125
|
+
config_values: Non-secret configuration values (e.g., {"subdomain": "mycompany"})
|
|
126
|
+
Used for server variables and template substitution in OAuth2 refresh URLs.
|
|
127
|
+
client: Optional HTTPClientProtocol implementation. If None, creates HTTPXClient.
|
|
128
|
+
logger: Optional RequestLogger instance for logging requests/responses
|
|
129
|
+
max_connections: Maximum number of concurrent connections
|
|
130
|
+
max_keepalive_connections: Maximum number of keepalive connections
|
|
131
|
+
timeout: Default timeout in seconds (used if connect/read not specified)
|
|
132
|
+
connect_timeout: Connection timeout in seconds
|
|
133
|
+
read_timeout: Read timeout in seconds
|
|
134
|
+
on_token_refresh: Optional callback for OAuth2 token refresh persistence.
|
|
135
|
+
Signature: (new_tokens: dict[str, str]) -> None (sync or async).
|
|
136
|
+
Called when tokens are refreshed. Use to persist updated tokens.
|
|
137
|
+
retry_config: Optional retry configuration for transient errors.
|
|
138
|
+
If None, uses default RetryConfig with sensible defaults.
|
|
139
|
+
"""
|
|
140
|
+
# Store original base_url template for re-rendering after token extraction
|
|
141
|
+
self._base_url_template = base_url.rstrip("/")
|
|
142
|
+
self.config_values = config_values or {}
|
|
143
|
+
|
|
144
|
+
# Substitute server variables in base_url (e.g., {subdomain} -> "mycompany")
|
|
145
|
+
self.base_url = self._base_url_template
|
|
146
|
+
for var_name, var_value in self.config_values.items():
|
|
147
|
+
self.base_url = self.base_url.replace(f"{{{var_name}}}", var_value)
|
|
148
|
+
|
|
149
|
+
self.auth_config = auth_config
|
|
150
|
+
assert (
|
|
151
|
+
self.auth_config.type is not None
|
|
152
|
+
), "auth_config.type cannot be None" # Should never be None when instantiated via the local executor flow
|
|
153
|
+
self.secrets = secrets
|
|
154
|
+
self.logger = logger or NullLogger()
|
|
155
|
+
self.metrics = HTTPMetrics()
|
|
156
|
+
self.on_token_refresh: TokenRefreshCallback = on_token_refresh
|
|
157
|
+
self.retry_config = retry_config or RetryConfig()
|
|
158
|
+
|
|
159
|
+
# Auth error handling with refresh lock (for strategies that support refresh)
|
|
160
|
+
self._refresh_lock = asyncio.Lock()
|
|
161
|
+
# Track whether proactive credential initialization has been performed
|
|
162
|
+
self._credentials_initialized = False
|
|
163
|
+
|
|
164
|
+
# Validate base URL template
|
|
165
|
+
if not base_url:
|
|
166
|
+
raise ValueError("base_url cannot be empty")
|
|
167
|
+
|
|
168
|
+
# Check if base_url has unresolved template variables (e.g., {instance_url})
|
|
169
|
+
# These will be resolved later from token_extract in OAuth2 flows
|
|
170
|
+
has_unresolved_variables = "{" in self.base_url and "}" in self.base_url
|
|
171
|
+
|
|
172
|
+
# Only validate URL format if there are no unresolved variables
|
|
173
|
+
if not has_unresolved_variables and not self.base_url.startswith(("http://", "https://")):
|
|
174
|
+
raise ValueError(f"base_url must start with http:// or https://, got: {self.base_url}")
|
|
175
|
+
|
|
176
|
+
# Create HTTP client if not provided
|
|
177
|
+
if client is None:
|
|
178
|
+
# Create default client configuration
|
|
179
|
+
config = ClientConfig(
|
|
180
|
+
base_url=None, # We handle base_url ourselves
|
|
181
|
+
limits=ConnectionLimits(
|
|
182
|
+
max_connections=max_connections,
|
|
183
|
+
max_keepalive_connections=max_keepalive_connections,
|
|
184
|
+
),
|
|
185
|
+
timeout=TimeoutConfig(
|
|
186
|
+
connect=connect_timeout or DEFAULT_CONNECT_TIMEOUT,
|
|
187
|
+
read=read_timeout or timeout,
|
|
188
|
+
write=timeout,
|
|
189
|
+
pool=timeout,
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
client = HTTPXClient(config=config)
|
|
193
|
+
|
|
194
|
+
self.client = client
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def create_default(
|
|
198
|
+
cls,
|
|
199
|
+
base_url: str,
|
|
200
|
+
auth_config: AuthConfig,
|
|
201
|
+
secrets: dict[str, SecretStr | str],
|
|
202
|
+
logger: Any | None = None,
|
|
203
|
+
**kwargs: Any,
|
|
204
|
+
) -> HTTPClient:
|
|
205
|
+
"""Create an HTTPClient with default HTTP client (HTTPXClient).
|
|
206
|
+
|
|
207
|
+
This is a convenience factory method for the common case of using httpx.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
base_url: Base URL for API (e.g., https://api.stripe.com)
|
|
211
|
+
auth_config: Authentication configuration from connector.yaml
|
|
212
|
+
secrets: Secret credentials (SecretStr or plain str values)
|
|
213
|
+
logger: Optional RequestLogger instance for logging requests/responses
|
|
214
|
+
**kwargs: Additional arguments passed to __init__
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Configured HTTPClient instance with HTTPXClient
|
|
218
|
+
"""
|
|
219
|
+
return cls(
|
|
220
|
+
base_url=base_url,
|
|
221
|
+
auth_config=auth_config,
|
|
222
|
+
secrets=secrets,
|
|
223
|
+
client=None, # Will create default HTTPXClient
|
|
224
|
+
logger=logger,
|
|
225
|
+
**kwargs,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _validate_auth_credentials(self) -> None:
|
|
229
|
+
"""Validate that required auth credentials are present.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
AuthenticationError: If required credentials are missing
|
|
233
|
+
"""
|
|
234
|
+
if self.auth_config.type == AuthType.API_KEY:
|
|
235
|
+
api_key = self.secrets.get("api_key")
|
|
236
|
+
if not api_key:
|
|
237
|
+
raise AuthenticationError("Missing required credential 'api_key' for API_KEY authentication")
|
|
238
|
+
|
|
239
|
+
elif self.auth_config.type == AuthType.BEARER:
|
|
240
|
+
token = self.secrets.get("token") or self.secrets.get("api_key")
|
|
241
|
+
if not token:
|
|
242
|
+
raise AuthenticationError("Missing required credential 'token' or 'api_key' for BEARER authentication")
|
|
243
|
+
|
|
244
|
+
elif self.auth_config.type == AuthType.BASIC:
|
|
245
|
+
username = self.secrets.get("username")
|
|
246
|
+
password = self.secrets.get("password")
|
|
247
|
+
if not username or not password:
|
|
248
|
+
raise AuthenticationError("Missing required credentials 'username' and 'password' for BASIC authentication")
|
|
249
|
+
|
|
250
|
+
def _inject_auth(self, headers: dict[str, str]) -> dict[str, str]:
|
|
251
|
+
"""Inject authentication into request headers.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
headers: Existing headers
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Headers with authentication added
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
AuthenticationError: If required credentials are missing
|
|
261
|
+
"""
|
|
262
|
+
strategy = AuthStrategyFactory.get_strategy(self.auth_config.type)
|
|
263
|
+
return strategy.inject_auth(headers, self.auth_config.config, self.secrets)
|
|
264
|
+
|
|
265
|
+
async def _ensure_auth_initialized(self) -> None:
|
|
266
|
+
"""Ensure authentication credentials are initialized.
|
|
267
|
+
|
|
268
|
+
For auth strategies that support proactive credential acquisition
|
|
269
|
+
(e.g., OAuth2 refresh-token-only mode), this method is called
|
|
270
|
+
before the first request to obtain necessary credentials.
|
|
271
|
+
|
|
272
|
+
Thread-safe via _refresh_lock. Only runs once per HTTPClient instance.
|
|
273
|
+
"""
|
|
274
|
+
if self._credentials_initialized:
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
async with self._refresh_lock:
|
|
278
|
+
# Double-check after acquiring lock (another request may have initialized)
|
|
279
|
+
if self._credentials_initialized:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
strategy = AuthStrategyFactory.get_strategy(self.auth_config.type)
|
|
283
|
+
|
|
284
|
+
result = await strategy.ensure_credentials(
|
|
285
|
+
config=self.auth_config.config,
|
|
286
|
+
secrets=self.secrets,
|
|
287
|
+
config_values=self.config_values,
|
|
288
|
+
http_client=None, # Let strategy create its own client
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if result:
|
|
292
|
+
# Notify callback if provided (for persistence)
|
|
293
|
+
if self.on_token_refresh is not None:
|
|
294
|
+
try:
|
|
295
|
+
# Build callback data with both tokens and extracted values
|
|
296
|
+
callback_data = dict(result.tokens)
|
|
297
|
+
if result.extracted_values:
|
|
298
|
+
callback_data.update(result.extracted_values)
|
|
299
|
+
|
|
300
|
+
# Support both sync and async callbacks
|
|
301
|
+
callback_result = self.on_token_refresh(callback_data)
|
|
302
|
+
if callback_result is not None and hasattr(callback_result, "__await__"):
|
|
303
|
+
await callback_result
|
|
304
|
+
except Exception as callback_error:
|
|
305
|
+
self.logger.log_error(
|
|
306
|
+
request_id=None,
|
|
307
|
+
error=(f"Token refresh callback failed during initialization: {callback_error!s}"),
|
|
308
|
+
status_code=None,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Update secrets with new tokens (in-memory)
|
|
312
|
+
self.secrets.update(result.tokens)
|
|
313
|
+
|
|
314
|
+
# Update config_values and re-render base_url with extracted values
|
|
315
|
+
if result.extracted_values:
|
|
316
|
+
self._apply_token_extract(result.extracted_values)
|
|
317
|
+
|
|
318
|
+
self._credentials_initialized = True
|
|
319
|
+
|
|
320
|
+
def _apply_token_extract(self, extracted_values: dict[str, str]) -> None:
|
|
321
|
+
"""Apply extracted token values to config_values and re-render base_url.
|
|
322
|
+
|
|
323
|
+
This method is called after OAuth2 token refresh when the token response
|
|
324
|
+
includes values specified by x-airbyte-token-extract (e.g., instance_url
|
|
325
|
+
for Salesforce).
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
extracted_values: Dictionary of field name -> value extracted from
|
|
329
|
+
the token response
|
|
330
|
+
"""
|
|
331
|
+
# Update config_values with extracted values
|
|
332
|
+
self.config_values.update(extracted_values)
|
|
333
|
+
|
|
334
|
+
# Re-render base_url with updated config_values
|
|
335
|
+
self.base_url = self._base_url_template
|
|
336
|
+
for var_name, var_value in self.config_values.items():
|
|
337
|
+
self.base_url = self.base_url.replace(f"{{{var_name}}}", var_value)
|
|
338
|
+
|
|
339
|
+
def _should_retry(
|
|
340
|
+
self,
|
|
341
|
+
exception: Exception,
|
|
342
|
+
status_code: int | None,
|
|
343
|
+
attempt: int,
|
|
344
|
+
) -> bool:
|
|
345
|
+
"""Determine if a request should be retried.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
exception: The exception that was raised
|
|
349
|
+
status_code: HTTP status code if available
|
|
350
|
+
attempt: Current attempt number (0-indexed)
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
True if the request should be retried
|
|
354
|
+
"""
|
|
355
|
+
# Check if we have retries remaining
|
|
356
|
+
if attempt >= self.retry_config.max_attempts - 1:
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
# Check status code-based retries
|
|
360
|
+
if status_code and status_code in self.retry_config.retry_on_status_codes:
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
# Check timeout retries
|
|
364
|
+
if self.retry_config.retry_on_timeout and isinstance(exception, TimeoutError):
|
|
365
|
+
return True
|
|
366
|
+
|
|
367
|
+
# Check network error retries
|
|
368
|
+
if self.retry_config.retry_on_network_error and isinstance(exception, NetworkError):
|
|
369
|
+
return True
|
|
370
|
+
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
def _calculate_delay(self, attempt: int, response_headers: dict[str, str]) -> float:
|
|
374
|
+
"""Calculate delay before the next retry attempt.
|
|
375
|
+
|
|
376
|
+
Prefers Retry-After header if present, otherwise uses exponential backoff
|
|
377
|
+
with optional jitter.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
attempt: The current attempt number (0-indexed)
|
|
381
|
+
response_headers: Response headers from the failed request
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Delay in seconds before the next retry
|
|
385
|
+
"""
|
|
386
|
+
# Try Retry-After header first
|
|
387
|
+
header_name = self.retry_config.retry_after_header
|
|
388
|
+
header_value = response_headers.get(header_name) or response_headers.get(header_name.lower())
|
|
389
|
+
|
|
390
|
+
if header_value:
|
|
391
|
+
try:
|
|
392
|
+
value = float(header_value)
|
|
393
|
+
if self.retry_config.retry_after_format == "milliseconds":
|
|
394
|
+
delay = value / 1000.0
|
|
395
|
+
elif self.retry_config.retry_after_format == "unix_timestamp":
|
|
396
|
+
delay = max(0.0, value - time.time())
|
|
397
|
+
else:
|
|
398
|
+
delay = value
|
|
399
|
+
return min(delay, self.retry_config.max_delay_seconds)
|
|
400
|
+
except (ValueError, TypeError):
|
|
401
|
+
pass # Fall through to exponential backoff
|
|
402
|
+
|
|
403
|
+
# Exponential backoff: initial_delay * (base ^ attempt)
|
|
404
|
+
delay = self.retry_config.initial_delay_seconds * (self.retry_config.exponential_base**attempt)
|
|
405
|
+
delay = min(delay, self.retry_config.max_delay_seconds)
|
|
406
|
+
|
|
407
|
+
# Apply full jitter to prevent thundering herd
|
|
408
|
+
# See: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
|
|
409
|
+
if self.retry_config.jitter:
|
|
410
|
+
delay = random.random() * delay
|
|
411
|
+
|
|
412
|
+
return delay
|
|
413
|
+
|
|
414
|
+
async def _execute_request(
|
|
415
|
+
self,
|
|
416
|
+
method: str,
|
|
417
|
+
path: str,
|
|
418
|
+
params: dict[str, Any] | None = None,
|
|
419
|
+
json: dict[str, Any] | None = None,
|
|
420
|
+
data: dict[str, Any] | None = None,
|
|
421
|
+
headers: dict[str, str] | None = None,
|
|
422
|
+
*,
|
|
423
|
+
stream: bool = False,
|
|
424
|
+
):
|
|
425
|
+
"""Execute a single HTTP request attempt (no retries).
|
|
426
|
+
|
|
427
|
+
This is the core request logic, separated from retry handling.
|
|
428
|
+
"""
|
|
429
|
+
# Ensure auth credentials are initialized (proactive refresh if needed)
|
|
430
|
+
await self._ensure_auth_initialized()
|
|
431
|
+
|
|
432
|
+
# Check if path is a full URL (for CDN/external URLs)
|
|
433
|
+
is_external_url = path.startswith(("http://", "https://")) and not path.startswith(self.base_url)
|
|
434
|
+
url = path if path.startswith(("http://", "https://")) else f"{self.base_url}{path}"
|
|
435
|
+
|
|
436
|
+
# Prepare headers with auth (skip for external URLs like pre-signed S3)
|
|
437
|
+
request_headers = headers or {}
|
|
438
|
+
if not is_external_url:
|
|
439
|
+
request_headers = self._inject_auth(request_headers)
|
|
440
|
+
|
|
441
|
+
# Log request start
|
|
442
|
+
request_id = self.logger.log_request(
|
|
443
|
+
method=method.upper(),
|
|
444
|
+
url=url,
|
|
445
|
+
path=path,
|
|
446
|
+
headers=request_headers,
|
|
447
|
+
params=params,
|
|
448
|
+
body=json or data,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Track timing
|
|
452
|
+
start_time = datetime.now()
|
|
453
|
+
success = False
|
|
454
|
+
status_code = 0
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
# Make async request through HTTP client protocol
|
|
458
|
+
response = await self.client.request(
|
|
459
|
+
method=method.upper(),
|
|
460
|
+
url=url,
|
|
461
|
+
params=params,
|
|
462
|
+
json=json,
|
|
463
|
+
data=data,
|
|
464
|
+
headers=request_headers,
|
|
465
|
+
stream=stream,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
status_code = response.status_code
|
|
469
|
+
|
|
470
|
+
# Streaming path: return response without reading body
|
|
471
|
+
if stream:
|
|
472
|
+
success = True
|
|
473
|
+
self.logger.log_response(
|
|
474
|
+
request_id=request_id,
|
|
475
|
+
status_code=status_code,
|
|
476
|
+
response_body=f"<binary content, {response.headers.get('content-length', 'unknown')} bytes>",
|
|
477
|
+
)
|
|
478
|
+
return response
|
|
479
|
+
|
|
480
|
+
# Parse response - handle non-JSON responses gracefully
|
|
481
|
+
content_type = response.headers.get("content-type", "")
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
response_text = await response.text()
|
|
485
|
+
|
|
486
|
+
if not response_text.strip():
|
|
487
|
+
response_data = {}
|
|
488
|
+
elif "application/json" in content_type or not content_type:
|
|
489
|
+
response_data = await response.json()
|
|
490
|
+
else:
|
|
491
|
+
error_msg = f"Expected JSON response for {method.upper()} {url}, got content-type: {content_type}"
|
|
492
|
+
raise HTTPClientError(error_msg)
|
|
493
|
+
|
|
494
|
+
except ValueError as e:
|
|
495
|
+
error_msg = f"Failed to parse JSON response for {method.upper()} {url}: {str(e)}"
|
|
496
|
+
raise HTTPClientError(error_msg)
|
|
497
|
+
|
|
498
|
+
success = True
|
|
499
|
+
self.logger.log_response(
|
|
500
|
+
request_id=request_id,
|
|
501
|
+
status_code=status_code,
|
|
502
|
+
response_body=response_data,
|
|
503
|
+
)
|
|
504
|
+
return response_data
|
|
505
|
+
|
|
506
|
+
except AuthenticationError as e:
|
|
507
|
+
# Auth error (401, 403) - handle token refresh
|
|
508
|
+
status_code = e.status_code if hasattr(e, "status_code") else 401
|
|
509
|
+
result = await self._handle_auth_error(e, request_id, method, path, params, json, data, headers)
|
|
510
|
+
if result is not None:
|
|
511
|
+
return result # Token refresh succeeded, return the retry result
|
|
512
|
+
raise # Token refresh failed or not applicable
|
|
513
|
+
|
|
514
|
+
except (RateLimitError, HTTPStatusError, TimeoutError, NetworkError) as e:
|
|
515
|
+
# These may be retried by the caller
|
|
516
|
+
status_code = getattr(e, "status_code", 0) or 0
|
|
517
|
+
self.logger.log_error(request_id=request_id, error=str(e), status_code=status_code or None)
|
|
518
|
+
raise
|
|
519
|
+
|
|
520
|
+
except HTTPClientError as e:
|
|
521
|
+
self.logger.log_error(
|
|
522
|
+
request_id=request_id,
|
|
523
|
+
error=str(e),
|
|
524
|
+
status_code=status_code if status_code else None,
|
|
525
|
+
)
|
|
526
|
+
raise
|
|
527
|
+
|
|
528
|
+
except Exception as e:
|
|
529
|
+
error_msg = f"Unexpected error for {method.upper()} {url}: {str(e)}"
|
|
530
|
+
self.logger.log_error(
|
|
531
|
+
request_id=request_id,
|
|
532
|
+
error=error_msg,
|
|
533
|
+
status_code=status_code if status_code else None,
|
|
534
|
+
)
|
|
535
|
+
raise HTTPClientError(error_msg)
|
|
536
|
+
|
|
537
|
+
finally:
|
|
538
|
+
duration = (datetime.now() - start_time).total_seconds()
|
|
539
|
+
self.metrics.record_request(duration, status_code, success)
|
|
540
|
+
|
|
541
|
+
async def _handle_auth_error(
|
|
542
|
+
self,
|
|
543
|
+
error: AuthenticationError,
|
|
544
|
+
request_id: str,
|
|
545
|
+
method: str,
|
|
546
|
+
path: str,
|
|
547
|
+
params: dict[str, Any] | None,
|
|
548
|
+
json: dict[str, Any] | None,
|
|
549
|
+
data: dict[str, Any] | None,
|
|
550
|
+
headers: dict[str, str] | None,
|
|
551
|
+
):
|
|
552
|
+
"""Handle authentication error with potential token refresh.
|
|
553
|
+
|
|
554
|
+
Raises the original error if refresh fails or is not applicable.
|
|
555
|
+
"""
|
|
556
|
+
status_code = error.status_code if hasattr(error, "status_code") else 401
|
|
557
|
+
|
|
558
|
+
async with self._refresh_lock:
|
|
559
|
+
current_token = self.secrets.get("access_token")
|
|
560
|
+
strategy = AuthStrategyFactory.get_strategy(self.auth_config.type)
|
|
561
|
+
|
|
562
|
+
# Try to refresh credentials
|
|
563
|
+
try:
|
|
564
|
+
result = await strategy.handle_auth_error(
|
|
565
|
+
status_code=status_code,
|
|
566
|
+
config=self.auth_config.config,
|
|
567
|
+
secrets=self.secrets,
|
|
568
|
+
config_values=self.config_values,
|
|
569
|
+
http_client=None, # Let strategy create its own client
|
|
570
|
+
)
|
|
571
|
+
except Exception as refresh_error:
|
|
572
|
+
self.logger.log_error(
|
|
573
|
+
request_id=request_id,
|
|
574
|
+
error=f"Credential refresh failed: {str(refresh_error)}",
|
|
575
|
+
status_code=status_code,
|
|
576
|
+
)
|
|
577
|
+
result = None
|
|
578
|
+
|
|
579
|
+
# If refresh succeeded, update tokens and retry
|
|
580
|
+
if result:
|
|
581
|
+
# Notify callback if provided (for persistence)
|
|
582
|
+
# Include both tokens AND extracted values for full persistence
|
|
583
|
+
if self.on_token_refresh is not None:
|
|
584
|
+
try:
|
|
585
|
+
# Build callback data with both tokens and extracted values
|
|
586
|
+
callback_data = dict(result.tokens)
|
|
587
|
+
if result.extracted_values:
|
|
588
|
+
callback_data.update(result.extracted_values)
|
|
589
|
+
|
|
590
|
+
# Support both sync and async callbacks
|
|
591
|
+
callback_result = self.on_token_refresh(callback_data)
|
|
592
|
+
if callback_result is not None and hasattr(callback_result, "__await__"):
|
|
593
|
+
await callback_result
|
|
594
|
+
except Exception as callback_error:
|
|
595
|
+
self.logger.log_error(
|
|
596
|
+
request_id=request_id,
|
|
597
|
+
error=f"Token refresh callback failed: {str(callback_error)}",
|
|
598
|
+
status_code=status_code,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# Update secrets with new tokens (in-memory)
|
|
602
|
+
self.secrets.update(result.tokens)
|
|
603
|
+
|
|
604
|
+
# Update config_values and re-render base_url with extracted values
|
|
605
|
+
if result.extracted_values:
|
|
606
|
+
self._apply_token_extract(result.extracted_values)
|
|
607
|
+
|
|
608
|
+
if self.secrets.get("access_token") != current_token:
|
|
609
|
+
# Retry with new token - this will go through full retry logic
|
|
610
|
+
# Any errors from this retry will propagate to the caller
|
|
611
|
+
return await self.request(
|
|
612
|
+
method=method,
|
|
613
|
+
path=path,
|
|
614
|
+
params=params,
|
|
615
|
+
json=json,
|
|
616
|
+
data=data,
|
|
617
|
+
headers=headers,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
# Refresh failed or token didn't change, log and let original error propagate
|
|
621
|
+
self.logger.log_error(request_id=request_id, error=str(error), status_code=status_code)
|
|
622
|
+
|
|
623
|
+
async def request(
|
|
624
|
+
self,
|
|
625
|
+
method: str,
|
|
626
|
+
path: str,
|
|
627
|
+
params: dict[str, Any] | None = None,
|
|
628
|
+
json: dict[str, Any] | None = None,
|
|
629
|
+
data: dict[str, Any] | None = None,
|
|
630
|
+
headers: dict[str, str] | None = None,
|
|
631
|
+
*,
|
|
632
|
+
stream: bool = False,
|
|
633
|
+
_auth_retry_attempted: bool = False,
|
|
634
|
+
):
|
|
635
|
+
"""Make an async HTTP request with optional streaming and automatic retries.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
method: HTTP method (GET, POST, etc.)
|
|
639
|
+
path: API path or full URL
|
|
640
|
+
params: Query parameters
|
|
641
|
+
json: JSON body for POST/PUT
|
|
642
|
+
data: Form-encoded body for POST/PUT (mutually exclusive with json)
|
|
643
|
+
headers: Additional headers
|
|
644
|
+
stream: If True, do not eagerly read the body (useful for downloads)
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
- If stream=False: Parsed JSON (dict) or empty dict
|
|
648
|
+
- If stream=True: Response object suitable for streaming
|
|
649
|
+
|
|
650
|
+
Raises:
|
|
651
|
+
HTTPStatusError: If request fails with 4xx/5xx status after all retries
|
|
652
|
+
AuthenticationError: For 401 or 403 status codes
|
|
653
|
+
RateLimitError: For 429 status codes (after all retries if configured)
|
|
654
|
+
TimeoutError: If request times out (after all retries if configured)
|
|
655
|
+
NetworkError: If network error occurs (after all retries if configured)
|
|
656
|
+
HTTPClientError: For other client errors
|
|
657
|
+
"""
|
|
658
|
+
for attempt in range(self.retry_config.max_attempts):
|
|
659
|
+
try:
|
|
660
|
+
return await self._execute_request(method, path, params, json, data, headers, stream=stream)
|
|
661
|
+
except (RateLimitError, HTTPStatusError, TimeoutError, NetworkError) as e:
|
|
662
|
+
status_code = getattr(e, "status_code", None)
|
|
663
|
+
headers_from_error = getattr(e, "headers", {}) or {}
|
|
664
|
+
|
|
665
|
+
if not self._should_retry(e, status_code, attempt):
|
|
666
|
+
raise
|
|
667
|
+
|
|
668
|
+
delay = self._calculate_delay(attempt, headers_from_error)
|
|
669
|
+
self.metrics.record_retry(delay)
|
|
670
|
+
await asyncio.sleep(delay)
|
|
671
|
+
# AuthenticationError, HTTPClientError, and other exceptions propagate immediately
|
|
672
|
+
|
|
673
|
+
# Should not reach here, but just in case
|
|
674
|
+
raise HTTPClientError("Exhausted all retry attempts")
|
|
675
|
+
|
|
676
|
+
async def close(self):
|
|
677
|
+
"""Close the async HTTP client."""
|
|
678
|
+
await self.client.aclose()
|
|
679
|
+
|
|
680
|
+
async def __aenter__(self):
|
|
681
|
+
"""Async context manager entry."""
|
|
682
|
+
return self
|
|
683
|
+
|
|
684
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
685
|
+
"""Async context manager exit."""
|
|
686
|
+
await self.close()
|