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