airbyte-agent-stripe 0.5.28__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 (55) hide show
  1. airbyte_agent_stripe/__init__.py +237 -0
  2. airbyte_agent_stripe/_vendored/__init__.py +1 -0
  3. airbyte_agent_stripe/_vendored/connector_sdk/__init__.py +82 -0
  4. airbyte_agent_stripe/_vendored/connector_sdk/auth_strategies.py +1123 -0
  5. airbyte_agent_stripe/_vendored/connector_sdk/auth_template.py +135 -0
  6. airbyte_agent_stripe/_vendored/connector_sdk/cloud_utils/__init__.py +5 -0
  7. airbyte_agent_stripe/_vendored/connector_sdk/cloud_utils/client.py +213 -0
  8. airbyte_agent_stripe/_vendored/connector_sdk/connector_model_loader.py +957 -0
  9. airbyte_agent_stripe/_vendored/connector_sdk/constants.py +78 -0
  10. airbyte_agent_stripe/_vendored/connector_sdk/exceptions.py +23 -0
  11. airbyte_agent_stripe/_vendored/connector_sdk/executor/__init__.py +31 -0
  12. airbyte_agent_stripe/_vendored/connector_sdk/executor/hosted_executor.py +197 -0
  13. airbyte_agent_stripe/_vendored/connector_sdk/executor/local_executor.py +1524 -0
  14. airbyte_agent_stripe/_vendored/connector_sdk/executor/models.py +190 -0
  15. airbyte_agent_stripe/_vendored/connector_sdk/extensions.py +655 -0
  16. airbyte_agent_stripe/_vendored/connector_sdk/http/__init__.py +37 -0
  17. airbyte_agent_stripe/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
  18. airbyte_agent_stripe/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
  19. airbyte_agent_stripe/_vendored/connector_sdk/http/config.py +98 -0
  20. airbyte_agent_stripe/_vendored/connector_sdk/http/exceptions.py +119 -0
  21. airbyte_agent_stripe/_vendored/connector_sdk/http/protocols.py +114 -0
  22. airbyte_agent_stripe/_vendored/connector_sdk/http/response.py +102 -0
  23. airbyte_agent_stripe/_vendored/connector_sdk/http_client.py +686 -0
  24. airbyte_agent_stripe/_vendored/connector_sdk/logging/__init__.py +11 -0
  25. airbyte_agent_stripe/_vendored/connector_sdk/logging/logger.py +264 -0
  26. airbyte_agent_stripe/_vendored/connector_sdk/logging/types.py +92 -0
  27. airbyte_agent_stripe/_vendored/connector_sdk/observability/__init__.py +11 -0
  28. airbyte_agent_stripe/_vendored/connector_sdk/observability/models.py +19 -0
  29. airbyte_agent_stripe/_vendored/connector_sdk/observability/redactor.py +81 -0
  30. airbyte_agent_stripe/_vendored/connector_sdk/observability/session.py +94 -0
  31. airbyte_agent_stripe/_vendored/connector_sdk/performance/__init__.py +6 -0
  32. airbyte_agent_stripe/_vendored/connector_sdk/performance/instrumentation.py +57 -0
  33. airbyte_agent_stripe/_vendored/connector_sdk/performance/metrics.py +93 -0
  34. airbyte_agent_stripe/_vendored/connector_sdk/schema/__init__.py +75 -0
  35. airbyte_agent_stripe/_vendored/connector_sdk/schema/base.py +161 -0
  36. airbyte_agent_stripe/_vendored/connector_sdk/schema/components.py +238 -0
  37. airbyte_agent_stripe/_vendored/connector_sdk/schema/connector.py +131 -0
  38. airbyte_agent_stripe/_vendored/connector_sdk/schema/extensions.py +109 -0
  39. airbyte_agent_stripe/_vendored/connector_sdk/schema/operations.py +146 -0
  40. airbyte_agent_stripe/_vendored/connector_sdk/schema/security.py +213 -0
  41. airbyte_agent_stripe/_vendored/connector_sdk/secrets.py +182 -0
  42. airbyte_agent_stripe/_vendored/connector_sdk/telemetry/__init__.py +10 -0
  43. airbyte_agent_stripe/_vendored/connector_sdk/telemetry/config.py +32 -0
  44. airbyte_agent_stripe/_vendored/connector_sdk/telemetry/events.py +58 -0
  45. airbyte_agent_stripe/_vendored/connector_sdk/telemetry/tracker.py +151 -0
  46. airbyte_agent_stripe/_vendored/connector_sdk/types.py +241 -0
  47. airbyte_agent_stripe/_vendored/connector_sdk/utils.py +60 -0
  48. airbyte_agent_stripe/_vendored/connector_sdk/validation.py +822 -0
  49. airbyte_agent_stripe/connector.py +1579 -0
  50. airbyte_agent_stripe/connector_model.py +14869 -0
  51. airbyte_agent_stripe/models.py +2353 -0
  52. airbyte_agent_stripe/types.py +295 -0
  53. airbyte_agent_stripe-0.5.28.dist-info/METADATA +114 -0
  54. airbyte_agent_stripe-0.5.28.dist-info/RECORD +55 -0
  55. airbyte_agent_stripe-0.5.28.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()
@@ -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
+ ]