sdkrouter 0.1.1__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 (96) hide show
  1. sdkrouter/__init__.py +110 -0
  2. sdkrouter/_api/__init__.py +28 -0
  3. sdkrouter/_api/client.py +204 -0
  4. sdkrouter/_api/generated/__init__.py +21 -0
  5. sdkrouter/_api/generated/cdn/__init__.py +209 -0
  6. sdkrouter/_api/generated/cdn/cdn__api__cdn/__init__.py +7 -0
  7. sdkrouter/_api/generated/cdn/cdn__api__cdn/client.py +133 -0
  8. sdkrouter/_api/generated/cdn/cdn__api__cdn/models.py +163 -0
  9. sdkrouter/_api/generated/cdn/cdn__api__cdn/sync_client.py +132 -0
  10. sdkrouter/_api/generated/cdn/client.py +75 -0
  11. sdkrouter/_api/generated/cdn/logger.py +256 -0
  12. sdkrouter/_api/generated/cdn/pyproject.toml +55 -0
  13. sdkrouter/_api/generated/cdn/retry.py +272 -0
  14. sdkrouter/_api/generated/cdn/sync_client.py +58 -0
  15. sdkrouter/_api/generated/cleaner/__init__.py +212 -0
  16. sdkrouter/_api/generated/cleaner/cleaner__api__cleaner/__init__.py +7 -0
  17. sdkrouter/_api/generated/cleaner/cleaner__api__cleaner/client.py +83 -0
  18. sdkrouter/_api/generated/cleaner/cleaner__api__cleaner/models.py +117 -0
  19. sdkrouter/_api/generated/cleaner/cleaner__api__cleaner/sync_client.py +82 -0
  20. sdkrouter/_api/generated/cleaner/client.py +75 -0
  21. sdkrouter/_api/generated/cleaner/enums.py +55 -0
  22. sdkrouter/_api/generated/cleaner/logger.py +256 -0
  23. sdkrouter/_api/generated/cleaner/pyproject.toml +55 -0
  24. sdkrouter/_api/generated/cleaner/retry.py +272 -0
  25. sdkrouter/_api/generated/cleaner/sync_client.py +58 -0
  26. sdkrouter/_api/generated/keys/__init__.py +212 -0
  27. sdkrouter/_api/generated/keys/client.py +75 -0
  28. sdkrouter/_api/generated/keys/enums.py +64 -0
  29. sdkrouter/_api/generated/keys/keys__api__keys/__init__.py +7 -0
  30. sdkrouter/_api/generated/keys/keys__api__keys/client.py +150 -0
  31. sdkrouter/_api/generated/keys/keys__api__keys/models.py +152 -0
  32. sdkrouter/_api/generated/keys/keys__api__keys/sync_client.py +149 -0
  33. sdkrouter/_api/generated/keys/logger.py +256 -0
  34. sdkrouter/_api/generated/keys/pyproject.toml +55 -0
  35. sdkrouter/_api/generated/keys/retry.py +272 -0
  36. sdkrouter/_api/generated/keys/sync_client.py +58 -0
  37. sdkrouter/_api/generated/models/__init__.py +209 -0
  38. sdkrouter/_api/generated/models/client.py +75 -0
  39. sdkrouter/_api/generated/models/logger.py +256 -0
  40. sdkrouter/_api/generated/models/models__api__llm_models/__init__.py +7 -0
  41. sdkrouter/_api/generated/models/models__api__llm_models/client.py +99 -0
  42. sdkrouter/_api/generated/models/models__api__llm_models/models.py +206 -0
  43. sdkrouter/_api/generated/models/models__api__llm_models/sync_client.py +99 -0
  44. sdkrouter/_api/generated/models/pyproject.toml +55 -0
  45. sdkrouter/_api/generated/models/retry.py +272 -0
  46. sdkrouter/_api/generated/models/sync_client.py +58 -0
  47. sdkrouter/_api/generated/shortlinks/__init__.py +209 -0
  48. sdkrouter/_api/generated/shortlinks/client.py +75 -0
  49. sdkrouter/_api/generated/shortlinks/logger.py +256 -0
  50. sdkrouter/_api/generated/shortlinks/pyproject.toml +55 -0
  51. sdkrouter/_api/generated/shortlinks/retry.py +272 -0
  52. sdkrouter/_api/generated/shortlinks/shortlinks__api__shortlinks/__init__.py +7 -0
  53. sdkrouter/_api/generated/shortlinks/shortlinks__api__shortlinks/client.py +137 -0
  54. sdkrouter/_api/generated/shortlinks/shortlinks__api__shortlinks/models.py +153 -0
  55. sdkrouter/_api/generated/shortlinks/shortlinks__api__shortlinks/sync_client.py +136 -0
  56. sdkrouter/_api/generated/shortlinks/sync_client.py +58 -0
  57. sdkrouter/_api/generated/vision/__init__.py +212 -0
  58. sdkrouter/_api/generated/vision/client.py +75 -0
  59. sdkrouter/_api/generated/vision/enums.py +40 -0
  60. sdkrouter/_api/generated/vision/logger.py +256 -0
  61. sdkrouter/_api/generated/vision/pyproject.toml +55 -0
  62. sdkrouter/_api/generated/vision/retry.py +272 -0
  63. sdkrouter/_api/generated/vision/sync_client.py +58 -0
  64. sdkrouter/_api/generated/vision/vision__api__vision/__init__.py +7 -0
  65. sdkrouter/_api/generated/vision/vision__api__vision/client.py +65 -0
  66. sdkrouter/_api/generated/vision/vision__api__vision/models.py +138 -0
  67. sdkrouter/_api/generated/vision/vision__api__vision/sync_client.py +65 -0
  68. sdkrouter/_client.py +432 -0
  69. sdkrouter/_config.py +74 -0
  70. sdkrouter/_constants.py +21 -0
  71. sdkrouter/_internal/__init__.py +1 -0
  72. sdkrouter/_types/__init__.py +30 -0
  73. sdkrouter/_types/cdn.py +27 -0
  74. sdkrouter/_types/models.py +26 -0
  75. sdkrouter/_types/ocr.py +24 -0
  76. sdkrouter/_types/parsed.py +101 -0
  77. sdkrouter/_types/shortlinks.py +27 -0
  78. sdkrouter/_types/vision.py +29 -0
  79. sdkrouter/_version.py +3 -0
  80. sdkrouter/helpers/__init__.py +13 -0
  81. sdkrouter/helpers/formatting.py +15 -0
  82. sdkrouter/helpers/html.py +100 -0
  83. sdkrouter/helpers/json_cleaner.py +53 -0
  84. sdkrouter/tools/__init__.py +129 -0
  85. sdkrouter/tools/cdn.py +285 -0
  86. sdkrouter/tools/cleaner.py +186 -0
  87. sdkrouter/tools/keys.py +215 -0
  88. sdkrouter/tools/models.py +196 -0
  89. sdkrouter/tools/shortlinks.py +165 -0
  90. sdkrouter/tools/vision.py +173 -0
  91. sdkrouter/utils/__init__.py +27 -0
  92. sdkrouter/utils/parsing.py +109 -0
  93. sdkrouter/utils/tokens.py +375 -0
  94. sdkrouter-0.1.1.dist-info/METADATA +411 -0
  95. sdkrouter-0.1.1.dist-info/RECORD +96 -0
  96. sdkrouter-0.1.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ import httpx
6
+
7
+ from .vision__api__vision import VisionVisionAPI
8
+ from .logger import APILogger, LoggerConfig
9
+ from .retry import RetryConfig, RetryAsyncClient
10
+
11
+
12
+ class APIClient:
13
+ """
14
+ Async API client for SDKRouter API.
15
+
16
+ Usage:
17
+ >>> async with APIClient(base_url='https://api.example.com') as client:
18
+ ... users = await client.users.list()
19
+ ... post = await client.posts.create(data=new_post)
20
+ >>>
21
+ >>> # With retry configuration
22
+ >>> retry_config = RetryConfig(max_attempts=5, min_wait=2.0)
23
+ >>> async with APIClient(base_url='https://api.example.com', retry_config=retry_config) as client:
24
+ ... users = await client.users.list()
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ base_url: str,
30
+ logger_config: Optional[LoggerConfig] = None,
31
+ retry_config: Optional[RetryConfig] = None,
32
+ **kwargs: Any,
33
+ ):
34
+ """
35
+ Initialize API client.
36
+
37
+ Args:
38
+ base_url: Base API URL (e.g., 'https://api.example.com')
39
+ logger_config: Logger configuration (None to disable logging)
40
+ retry_config: Retry configuration (None to disable retry)
41
+ **kwargs: Additional httpx.AsyncClient kwargs
42
+ """
43
+ self.base_url = base_url.rstrip('/')
44
+
45
+ # Create HTTP client with or without retry
46
+ if retry_config is not None:
47
+ self._client = RetryAsyncClient(
48
+ base_url=self.base_url,
49
+ retry_config=retry_config,
50
+ **kwargs,
51
+ )
52
+ else:
53
+ self._client = httpx.AsyncClient(
54
+ base_url=self.base_url,
55
+ **kwargs,
56
+ )
57
+
58
+ # Initialize logger
59
+ self.logger: Optional[APILogger] = None
60
+ if logger_config is not None:
61
+ self.logger = APILogger(logger_config)
62
+
63
+ # Initialize sub-clients
64
+ self.vision_vision = VisionVisionAPI(self._client)
65
+
66
+ async def __aenter__(self) -> 'APIClient':
67
+ await self._client.__aenter__()
68
+ return self
69
+
70
+ async def __aexit__(self, *args: Any) -> None:
71
+ await self._client.__aexit__(*args)
72
+
73
+ async def close(self) -> None:
74
+ """Close HTTP client."""
75
+ await self._client.aclose()
@@ -0,0 +1,40 @@
1
+ # Auto-generated by DjangoCFG - see CLAUDE.md
2
+ from enum import IntEnum, Enum
3
+
4
+ # Python 3.10 compatibility: StrEnum was added in Python 3.11
5
+ # Use str + Enum instead for backward compatibility
6
+ class StrEnum(str, Enum):
7
+ """String Enum for Python 3.10+ compatibility"""
8
+ pass
9
+
10
+
11
+ class OCRRequestRequestMode(StrEnum):
12
+ """
13
+ OCR mode affecting quality and speed
14
+ * `tiny` - Tiny - fastest, basic text extraction
15
+ * `small` - Small - fast, good for simple text
16
+ * `base` - Base - balanced speed and accuracy
17
+ * `maximum` - Maximum - best accuracy, slower
18
+ """
19
+
20
+ TINY = "tiny"
21
+ SMALL = "small"
22
+ BASE = "base"
23
+ MAXIMUM = "maximum"
24
+
25
+
26
+
27
+ class VisionAnalyzeRequestRequestModelQuality(StrEnum):
28
+ """
29
+ Model quality tier (fast, balanced, best)
30
+ * `fast` - Fast - lower quality, cheaper
31
+ * `balanced` - Balanced - good quality, reasonable cost
32
+ * `best` - Best - highest quality, most expensive
33
+ """
34
+
35
+ FAST = "fast"
36
+ BALANCED = "balanced"
37
+ BEST = "best"
38
+
39
+
40
+
@@ -0,0 +1,256 @@
1
+ # Auto-generated by DjangoCFG - see CLAUDE.md
2
+ """
3
+ API Logger with Rich
4
+ Beautiful console logging for API requests and responses
5
+
6
+ Installation:
7
+ pip install rich
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, Optional
15
+
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+ from rich.text import Text
20
+
21
+
22
+ @dataclass
23
+ class RequestLog:
24
+ """Request log data."""
25
+
26
+ method: str
27
+ url: str
28
+ headers: Optional[Dict[str, str]] = None
29
+ body: Optional[Any] = None
30
+ timestamp: float = field(default_factory=time.time)
31
+
32
+
33
+ @dataclass
34
+ class ResponseLog:
35
+ """Response log data."""
36
+
37
+ status: int
38
+ status_text: str
39
+ data: Optional[Any] = None
40
+ duration: float = 0.0
41
+ timestamp: float = field(default_factory=time.time)
42
+
43
+
44
+ @dataclass
45
+ class ErrorLog:
46
+ """Error log data."""
47
+
48
+ message: str
49
+ status_code: Optional[int] = None
50
+ field_errors: Optional[Dict[str, list[str]]] = None
51
+ duration: float = 0.0
52
+ timestamp: float = field(default_factory=time.time)
53
+
54
+
55
+ @dataclass
56
+ class LoggerConfig:
57
+ """Logger configuration."""
58
+
59
+ enabled: bool = True
60
+ log_requests: bool = True
61
+ log_responses: bool = True
62
+ log_errors: bool = True
63
+ log_bodies: bool = True
64
+ log_headers: bool = False
65
+ console: Optional[Console] = None
66
+
67
+
68
+ # Sensitive header names to filter out
69
+ SENSITIVE_HEADERS = [
70
+ "authorization",
71
+ "cookie",
72
+ "set-cookie",
73
+ "x-api-key",
74
+ "x-csrf-token",
75
+ ]
76
+
77
+
78
+ class APILogger:
79
+ """API Logger class."""
80
+
81
+ def __init__(self, config: Optional[LoggerConfig] = None):
82
+ """Initialize logger."""
83
+ self.config = config or LoggerConfig()
84
+ self.console = self.config.console or Console()
85
+
86
+ def enable(self) -> None:
87
+ """Enable logging."""
88
+ self.config.enabled = True
89
+
90
+ def disable(self) -> None:
91
+ """Disable logging."""
92
+ self.config.enabled = False
93
+
94
+ def set_config(self, **kwargs: Any) -> None:
95
+ """Update configuration."""
96
+ for key, value in kwargs.items():
97
+ if hasattr(self.config, key):
98
+ setattr(self.config, key, value)
99
+
100
+ def _filter_headers(self, headers: Optional[Dict[str, str]]) -> Dict[str, str]:
101
+ """Filter sensitive headers."""
102
+ if not headers:
103
+ return {}
104
+
105
+ filtered = {}
106
+ for key, value in headers.items():
107
+ if key.lower() in SENSITIVE_HEADERS:
108
+ filtered[key] = "***"
109
+ else:
110
+ filtered[key] = value
111
+
112
+ return filtered
113
+
114
+ def log_request(self, request: RequestLog) -> None:
115
+ """Log request."""
116
+ if not self.config.enabled or not self.config.log_requests:
117
+ return
118
+
119
+ # Create request info
120
+ text = Text()
121
+ text.append("→ ", style="bold blue")
122
+ text.append(request.method, style="bold yellow")
123
+ text.append(" ", style="")
124
+ text.append(request.url, style="cyan")
125
+
126
+ self.console.print(text)
127
+
128
+ if self.config.log_headers and request.headers:
129
+ headers = self._filter_headers(request.headers)
130
+ self.console.print(" Headers:", style="dim")
131
+ for key, value in headers.items():
132
+ self.console.print(f" {key}: {value}", style="dim")
133
+
134
+ if self.config.log_bodies and request.body:
135
+ self.console.print(" Body:", style="dim")
136
+ self.console.print(request.body, style="dim")
137
+
138
+ def log_response(self, request: RequestLog, response: ResponseLog) -> None:
139
+ """Log response."""
140
+ if not self.config.enabled or not self.config.log_responses:
141
+ return
142
+
143
+ # Determine color based on status
144
+ if response.status >= 500:
145
+ status_style = "bold red"
146
+ elif response.status >= 400:
147
+ status_style = "bold yellow"
148
+ elif response.status >= 300:
149
+ status_style = "bold cyan"
150
+ else:
151
+ status_style = "bold green"
152
+
153
+ # Create response info
154
+ text = Text()
155
+ text.append("← ", style="bold green")
156
+ text.append(request.method, style="bold yellow")
157
+ text.append(" ", style="")
158
+ text.append(request.url, style="cyan")
159
+ text.append(" ", style="")
160
+ text.append(str(response.status), style=status_style)
161
+ text.append(" ", style="")
162
+ text.append(response.status_text, style=status_style)
163
+ text.append(f" ({response.duration:.0f}ms)", style="dim")
164
+
165
+ self.console.print(text)
166
+
167
+ if self.config.log_bodies and response.data:
168
+ self.console.print(" Response:", style="dim")
169
+ self.console.print(response.data, style="dim")
170
+
171
+ def log_error(self, request: RequestLog, error: ErrorLog) -> None:
172
+ """Log error."""
173
+ if not self.config.enabled or not self.config.log_errors:
174
+ return
175
+
176
+ # Create error header
177
+ text = Text()
178
+ text.append("✗ ", style="bold red")
179
+ text.append(request.method, style="bold yellow")
180
+ text.append(" ", style="")
181
+ text.append(request.url, style="cyan")
182
+ text.append(" ", style="")
183
+ text.append(
184
+ str(error.status_code) if error.status_code else "Network",
185
+ style="bold red",
186
+ )
187
+ text.append(" Error", style="bold red")
188
+ text.append(f" ({error.duration:.0f}ms)", style="dim")
189
+
190
+ self.console.print(text)
191
+ self.console.print(f" Message: {error.message}", style="red")
192
+
193
+ if error.field_errors:
194
+ self.console.print(" Field Errors:", style="red")
195
+ for field, errors in error.field_errors.items():
196
+ for err in errors:
197
+ self.console.print(f" • {field}: {err}", style="red dim")
198
+
199
+ def info(self, message: str, **kwargs: Any) -> None:
200
+ """Log info message."""
201
+ if not self.config.enabled:
202
+ return
203
+ self.console.print(f"ℹ {message}", style="blue", **kwargs)
204
+
205
+ def warn(self, message: str, **kwargs: Any) -> None:
206
+ """Log warning message."""
207
+ if not self.config.enabled:
208
+ return
209
+ self.console.print(f"⚠ {message}", style="yellow", **kwargs)
210
+
211
+ def error(self, message: str, **kwargs: Any) -> None:
212
+ """Log error message."""
213
+ if not self.config.enabled:
214
+ return
215
+ self.console.print(f"✗ {message}", style="red", **kwargs)
216
+
217
+ def success(self, message: str, **kwargs: Any) -> None:
218
+ """Log success message."""
219
+ if not self.config.enabled:
220
+ return
221
+ self.console.print(f"✓ {message}", style="green", **kwargs)
222
+
223
+ def debug(self, message: str, **kwargs: Any) -> None:
224
+ """Log debug message."""
225
+ if not self.config.enabled:
226
+ return
227
+ self.console.print(f"🔍 {message}", style="dim", **kwargs)
228
+
229
+ def panel(self, content: Any, title: str, style: str = "blue") -> None:
230
+ """Log content in a panel."""
231
+ if not self.config.enabled:
232
+ return
233
+ self.console.print(Panel(content, title=title, border_style=style))
234
+
235
+ def table(
236
+ self,
237
+ headers: list[str],
238
+ rows: list[list[Any]],
239
+ title: Optional[str] = None,
240
+ ) -> None:
241
+ """Log data in a table."""
242
+ if not self.config.enabled:
243
+ return
244
+
245
+ table = Table(title=title)
246
+ for header in headers:
247
+ table.add_column(header, style="cyan")
248
+
249
+ for row in rows:
250
+ table.add_row(*[str(cell) for cell in row])
251
+
252
+ self.console.print(table)
253
+
254
+
255
+ # Default logger instance
256
+ default_logger = APILogger()
@@ -0,0 +1,55 @@
1
+ # Auto-generated by DjangoCFG - see CLAUDE.md
2
+ [tool.poetry]
3
+ name = "api-client"
4
+ version = "1.0.0"
5
+ description = "Auto-generated Python client for SDKRouter API"
6
+ authors = ["Author \u003cauthor@example.com\u003e"]
7
+ license = "MIT"
8
+ readme = "README.md"
9
+ keywords = ["api", "client", "python", "openapi"]
10
+ classifiers = [
11
+ "Development Status :: 5 - Production/Stable",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ [tool.poetry.dependencies]
20
+ python = "^3.12"
21
+ pydantic = "^2.12"
22
+ httpx = "^0.28"
23
+ tenacity = "^9.1"
24
+ rich = "^14.1.0"
25
+
26
+ [tool.poetry.group.dev.dependencies]
27
+ pytest = "^8.0"
28
+ pytest-asyncio = "^0.24"
29
+ pytest-cov = "^6.0"
30
+ mypy = "^1.18"
31
+ ruff = "^0.13"
32
+
33
+ [build-system]
34
+ requires = ["poetry-core"]
35
+ build-backend = "poetry.core.masonry.api"
36
+
37
+ [tool.mypy]
38
+ python_version = "3.12"
39
+ strict = true
40
+ warn_return_any = true
41
+ warn_unused_configs = true
42
+ disallow_untyped_defs = true
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ target-version = "py312"
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "I", "N", "UP", "B"]
50
+ ignore = []
51
+
52
+ [tool.pytest.ini_options]
53
+ asyncio_mode = "auto"
54
+ testpaths = ["tests"]
55
+ addopts = "--cov=api_client --cov-report=html --cov-report=term"
@@ -0,0 +1,272 @@
1
+ # Auto-generated by DjangoCFG - see CLAUDE.md
2
+ """
3
+ Retry Configuration and Utilities
4
+
5
+ Provides automatic retry logic for failed HTTP requests using tenacity.
6
+ Retries only on network errors and server errors (5xx), not client errors (4xx).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Callable, Any
13
+ import httpx
14
+ from tenacity import (
15
+ retry,
16
+ stop_after_attempt,
17
+ wait_exponential,
18
+ retry_if_exception,
19
+ RetryCallState,
20
+ before_sleep_log,
21
+ )
22
+ import logging
23
+
24
+
25
+ @dataclass
26
+ class RetryConfig:
27
+ """
28
+ Retry configuration options.
29
+
30
+ Uses exponential backoff with jitter by default to avoid thundering herd.
31
+ """
32
+
33
+ max_attempts: int = 3
34
+ """Maximum number of retry attempts (default: 3)"""
35
+
36
+ min_wait: float = 1.0
37
+ """Minimum wait time between retries in seconds (default: 1.0)"""
38
+
39
+ max_wait: float = 60.0
40
+ """Maximum wait time between retries in seconds (default: 60.0)"""
41
+
42
+ multiplier: float = 2.0
43
+ """Exponential backoff multiplier (default: 2.0)"""
44
+
45
+ on_retry: Callable[[RetryCallState], None] | None = None
46
+ """Callback called on each retry attempt"""
47
+
48
+ logger: logging.Logger | None = None
49
+ """Logger for retry attempts (default: None)"""
50
+
51
+
52
+ DEFAULT_RETRY_CONFIG = RetryConfig()
53
+ """Default retry configuration"""
54
+
55
+
56
+ def should_retry(exception: BaseException) -> bool:
57
+ """
58
+ Determine if an error should trigger a retry.
59
+
60
+ Retries on:
61
+ - Network errors (connection refused, timeout, etc.)
62
+ - Server errors (5xx status codes)
63
+ - Rate limiting (429 status code)
64
+
65
+ Does NOT retry on:
66
+ - Client errors (4xx except 429)
67
+ - Authentication errors (401, 403)
68
+ - Not found (404)
69
+
70
+ Args:
71
+ exception: The exception to check
72
+
73
+ Returns:
74
+ True if should retry, False otherwise
75
+ """
76
+ # Always retry network errors
77
+ if isinstance(exception, (
78
+ httpx.NetworkError,
79
+ httpx.TimeoutException,
80
+ httpx.ConnectError,
81
+ httpx.ReadError,
82
+ httpx.WriteError,
83
+ httpx.PoolTimeout,
84
+ )):
85
+ return True
86
+
87
+ # For HTTP errors, check status code
88
+ if isinstance(exception, httpx.HTTPStatusError):
89
+ status = exception.response.status_code
90
+
91
+ # Retry on 5xx server errors
92
+ if 500 <= status < 600:
93
+ return True
94
+
95
+ # Retry on 429 (rate limit)
96
+ if status == 429:
97
+ return True
98
+
99
+ # Do NOT retry on 4xx client errors
100
+ return False
101
+
102
+ # Don't retry on unknown errors
103
+ return False
104
+
105
+
106
+ def create_retry_decorator(config: RetryConfig | None = None):
107
+ """
108
+ Create a retry decorator with the given configuration.
109
+
110
+ Args:
111
+ config: Retry configuration (uses defaults if None)
112
+
113
+ Returns:
114
+ Tenacity retry decorator
115
+
116
+ Example:
117
+ >>> retry_decorator = create_retry_decorator(RetryConfig(max_attempts=5))
118
+ >>> @retry_decorator
119
+ ... async def fetch_data():
120
+ ... async with httpx.AsyncClient() as client:
121
+ ... response = await client.get('https://api.example.com/users')
122
+ ... response.raise_for_status()
123
+ ... return response.json()
124
+ """
125
+ cfg = config or DEFAULT_RETRY_CONFIG
126
+
127
+ # Build retry decorator
128
+ retry_args = {
129
+ 'stop': stop_after_attempt(cfg.max_attempts),
130
+ 'wait': wait_exponential(
131
+ multiplier=cfg.multiplier,
132
+ min=cfg.min_wait,
133
+ max=cfg.max_wait,
134
+ ),
135
+ 'retry': retry_if_exception(should_retry),
136
+ 'reraise': True,
137
+ }
138
+
139
+ # Add logger if provided
140
+ if cfg.logger:
141
+ retry_args['before_sleep'] = before_sleep_log(cfg.logger, logging.WARNING)
142
+
143
+ # Add custom callback if provided
144
+ if cfg.on_retry:
145
+ original_before_sleep = retry_args.get('before_sleep')
146
+
147
+ def combined_before_sleep(retry_state: RetryCallState):
148
+ if original_before_sleep:
149
+ original_before_sleep(retry_state)
150
+ if cfg.on_retry:
151
+ cfg.on_retry(retry_state)
152
+
153
+ retry_args['before_sleep'] = combined_before_sleep
154
+
155
+ return retry(**retry_args)
156
+
157
+
158
+ async def with_retry(
159
+ fn: Callable[..., Any],
160
+ config: RetryConfig | None = None,
161
+ *args,
162
+ **kwargs
163
+ ) -> Any:
164
+ """
165
+ Execute an async function with retry logic.
166
+
167
+ Args:
168
+ fn: Async function to retry
169
+ config: Retry configuration (uses defaults if None)
170
+ *args: Positional arguments for fn
171
+ **kwargs: Keyword arguments for fn
172
+
173
+ Returns:
174
+ Result of the function
175
+
176
+ Example:
177
+ >>> async def fetch_users():
178
+ ... async with httpx.AsyncClient() as client:
179
+ ... response = await client.get('https://api.example.com/users')
180
+ ... response.raise_for_status()
181
+ ... return response.json()
182
+ >>>
183
+ >>> result = await with_retry(fetch_users, RetryConfig(max_attempts=5))
184
+ """
185
+ retry_decorator = create_retry_decorator(config)
186
+ retryable_fn = retry_decorator(fn)
187
+ return await retryable_fn(*args, **kwargs)
188
+
189
+
190
+ class RetryAsyncClient:
191
+ """
192
+ HTTP client wrapper that adds automatic retry logic.
193
+
194
+ Wraps httpx.AsyncClient and applies retry logic to all HTTP methods.
195
+ Transparently retries on network errors, 5xx status codes, and 429 rate limits.
196
+
197
+ Example:
198
+ >>> async with RetryAsyncClient('https://api.example.com', retry_config=RetryConfig(max_attempts=5)) as client:
199
+ ... response = await client.get('/users')
200
+ ... response.raise_for_status()
201
+ """
202
+
203
+ def __init__(
204
+ self,
205
+ base_url: str | None = None,
206
+ retry_config: RetryConfig | None = None,
207
+ **kwargs: Any
208
+ ):
209
+ """
210
+ Initialize retry-enabled HTTP client.
211
+
212
+ Args:
213
+ base_url: Base URL for all requests
214
+ retry_config: Retry configuration (None to disable retry)
215
+ **kwargs: Additional httpx.AsyncClient kwargs
216
+ """
217
+ self._client = httpx.AsyncClient(base_url=base_url, **kwargs)
218
+ self.retry_config = retry_config
219
+ self._retry_decorator = create_retry_decorator(retry_config) if retry_config else None
220
+
221
+ async def __aenter__(self) -> 'RetryAsyncClient':
222
+ await self._client.__aenter__()
223
+ return self
224
+
225
+ async def __aexit__(self, *args: Any) -> None:
226
+ await self._client.__aexit__(*args)
227
+
228
+ async def aclose(self) -> None:
229
+ """Close the HTTP client."""
230
+ await self._client.aclose()
231
+
232
+ def _wrap_with_retry(self, method: str):
233
+ """Wrap HTTP method with retry logic."""
234
+ original_method = getattr(self._client, method)
235
+
236
+ if self._retry_decorator:
237
+ async def wrapped(*args, **kwargs):
238
+ @self._retry_decorator
239
+ async def _do_request():
240
+ return await original_method(*args, **kwargs)
241
+ return await _do_request()
242
+ return wrapped
243
+ else:
244
+ return original_method
245
+
246
+ async def get(self, *args, **kwargs) -> httpx.Response:
247
+ """GET request with retry."""
248
+ return await self._wrap_with_retry('get')(*args, **kwargs)
249
+
250
+ async def post(self, *args, **kwargs) -> httpx.Response:
251
+ """POST request with retry."""
252
+ return await self._wrap_with_retry('post')(*args, **kwargs)
253
+
254
+ async def put(self, *args, **kwargs) -> httpx.Response:
255
+ """PUT request with retry."""
256
+ return await self._wrap_with_retry('put')(*args, **kwargs)
257
+
258
+ async def patch(self, *args, **kwargs) -> httpx.Response:
259
+ """PATCH request with retry."""
260
+ return await self._wrap_with_retry('patch')(*args, **kwargs)
261
+
262
+ async def delete(self, *args, **kwargs) -> httpx.Response:
263
+ """DELETE request with retry."""
264
+ return await self._wrap_with_retry('delete')(*args, **kwargs)
265
+
266
+ async def head(self, *args, **kwargs) -> httpx.Response:
267
+ """HEAD request with retry."""
268
+ return await self._wrap_with_retry('head')(*args, **kwargs)
269
+
270
+ async def options(self, *args, **kwargs) -> httpx.Response:
271
+ """OPTIONS request with retry."""
272
+ return await self._wrap_with_retry('options')(*args, **kwargs)