smooai-fetch 2.1.2__tar.gz

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.
@@ -0,0 +1,57 @@
1
+ lib-cov
2
+ *.seed
3
+ *.log
4
+ *.csv
5
+ *.dat
6
+ *.out
7
+ *.pid
8
+ *.gz
9
+ *.swp
10
+
11
+ pids
12
+ logs
13
+ results
14
+ tmp
15
+
16
+ # Build
17
+ public/css/main.css
18
+
19
+ # Coverage reports
20
+ coverage
21
+
22
+ # API keys and secrets
23
+ .env
24
+
25
+ # Dependency directory
26
+ node_modules
27
+ bower_components
28
+
29
+ # Editors
30
+ .idea
31
+ *.iml
32
+
33
+ # OS metadata
34
+ .DS_Store
35
+ Thumbs.db
36
+
37
+ # Ignore built ts files
38
+ dist/**/*
39
+
40
+ # ignore yarn.lock
41
+ yarn.lock
42
+
43
+ # ignore package-lock.json
44
+ package-lock.json
45
+
46
+ .envrc
47
+ # Rust build artifacts
48
+ rust/fetch/target/
49
+
50
+ # Python build artifacts
51
+ python/dist/
52
+ python/.venv/
53
+ python/uv.lock
54
+ __pycache__/
55
+ *.pyc
56
+
57
+ # Go
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: smooai-fetch
3
+ Version: 2.1.2
4
+ Summary: A resilient HTTP fetch client with retries, timeouts, rate limiting, and circuit breaking.
5
+ Project-URL: Homepage, https://github.com/SmooAI/fetch
6
+ Project-URL: Repository, https://github.com/SmooAI/fetch
7
+ Project-URL: Issues, https://github.com/SmooAI/fetch/issues
8
+ Author-email: SmooAI <brent@smooai.com>
9
+ License: MIT
10
+ Keywords: circuit-breaker,fetch,http,retry,smooai
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: httpx>=0.27.0
18
+ Requires-Dist: pydantic>=2.0.0
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "smooai-fetch"
3
+ version = "2.1.2"
4
+ description = "A resilient HTTP fetch client with retries, timeouts, rate limiting, and circuit breaking."
5
+ # readme = "README.md"
6
+ authors = [{ name = "SmooAI", email = "brent@smooai.com" }]
7
+ license = { text = "MIT" }
8
+ requires-python = ">=3.12"
9
+ dependencies = ["httpx>=0.27.0", "pydantic>=2.0.0"]
10
+ classifiers = [
11
+ "Programming Language :: Python",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.12",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+ keywords = ["fetch", "http", "retry", "circuit-breaker", "smooai"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/SmooAI/fetch"
21
+ Repository = "https://github.com/SmooAI/fetch"
22
+ Issues = "https://github.com/SmooAI/fetch/issues"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/smooai_fetch"]
30
+
31
+ [dependency-groups]
32
+ dev = ["pytest>=8.0.0", "pytest-asyncio>=0.24.0", "respx>=0.22.0", "ruff>=0.11.0", "basedpyright>=1.0.0", "poethepoet>=0.29.0"]
33
+
34
+ [tool.poe.tasks]
35
+ lint = "ruff check src/ tests/"
36
+ "lint:fix" = "ruff check --fix src/ tests/"
37
+ format = "ruff format src/ tests/"
38
+ "format:check" = "ruff format --check src/ tests/"
39
+ typecheck = "basedpyright src/"
40
+ test = "uv run pytest tests/ -v"
41
+ build = "uv build --wheel --sdist"
42
+ "install-dev" = "uv sync --group dev"
43
+
44
+ [tool.poe.tasks.publish]
45
+ sequence = [{ cmd = "uv build --wheel --sdist" }, { cmd = "uv publish" }]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ pythonpath = ["src"]
50
+ asyncio_mode = "auto"
51
+
52
+ [tool.ruff]
53
+ target-version = "py312"
54
+ line-length = 120
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I", "N", "W", "UP"]
58
+ ignore = ["UP046", "UP047"] # Keep TypeVar/Generic style for broader compatibility
59
+
60
+ [tool.basedpyright]
61
+ pythonVersion = "3.12"
62
+ typeCheckingMode = "standard"
@@ -0,0 +1,91 @@
1
+ """Smoo AI Fetch Client - Python SDK.
2
+
3
+ A resilient HTTP fetch client with retries, timeouts, rate limiting,
4
+ and circuit breaking.
5
+ """
6
+
7
+ __version__ = "2.1.2"
8
+
9
+ # Core client
10
+ # Builder
11
+ from smooai_fetch._builder import FetchBuilder
12
+
13
+ # Circuit breaker
14
+ from smooai_fetch._circuit_breaker import CircuitBreaker
15
+ from smooai_fetch._client import fetch
16
+
17
+ # Defaults
18
+ from smooai_fetch._defaults import (
19
+ DEFAULT_RETRY_OPTIONS,
20
+ DEFAULT_TIMEOUT_MS,
21
+ DEFAULT_TIMEOUT_OPTIONS,
22
+ )
23
+
24
+ # Errors
25
+ from smooai_fetch._errors import (
26
+ CircuitBreakerError,
27
+ FetchError,
28
+ HTTPResponseError,
29
+ RateLimitError,
30
+ RetryError,
31
+ SchemaValidationError,
32
+ )
33
+ from smooai_fetch._errors import TimeoutError as TimeoutError
34
+
35
+ # Rate limiter
36
+ from smooai_fetch._rate_limit import SlidingWindowRateLimiter
37
+
38
+ # Response
39
+ from smooai_fetch._response import FetchResponse
40
+
41
+ # Retry utilities
42
+ from smooai_fetch._retry import calculate_backoff, is_retryable
43
+
44
+ # Types
45
+ from smooai_fetch._types import (
46
+ CircuitBreakerOptions,
47
+ FetchContainerOptions,
48
+ FetchOptions,
49
+ LifecycleHooks,
50
+ PostResponseErrorHook,
51
+ PostResponseSuccessHook,
52
+ PreRequestHook,
53
+ RateLimitOptions,
54
+ RetryOptions,
55
+ TimeoutOptions,
56
+ )
57
+
58
+ __all__ = [
59
+ # Core
60
+ "fetch",
61
+ "FetchBuilder",
62
+ "FetchResponse",
63
+ # Types
64
+ "CircuitBreakerOptions",
65
+ "FetchContainerOptions",
66
+ "FetchOptions",
67
+ "LifecycleHooks",
68
+ "PostResponseErrorHook",
69
+ "PostResponseSuccessHook",
70
+ "PreRequestHook",
71
+ "RateLimitOptions",
72
+ "RetryOptions",
73
+ "TimeoutOptions",
74
+ # Defaults
75
+ "DEFAULT_RETRY_OPTIONS",
76
+ "DEFAULT_TIMEOUT_MS",
77
+ "DEFAULT_TIMEOUT_OPTIONS",
78
+ # Errors
79
+ "CircuitBreakerError",
80
+ "FetchError",
81
+ "HTTPResponseError",
82
+ "RateLimitError",
83
+ "RetryError",
84
+ "SchemaValidationError",
85
+ "TimeoutError",
86
+ # Utilities
87
+ "calculate_backoff",
88
+ "is_retryable",
89
+ "SlidingWindowRateLimiter",
90
+ "CircuitBreaker",
91
+ ]
@@ -0,0 +1,230 @@
1
+ """Fluent builder for configuring fetch instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TypeVar
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from smooai_fetch._client import fetch as _fetch
10
+ from smooai_fetch._defaults import DEFAULT_RETRY_OPTIONS
11
+ from smooai_fetch._response import FetchResponse
12
+ from smooai_fetch._types import (
13
+ CircuitBreakerOptions,
14
+ FetchContainerOptions,
15
+ FetchOptions,
16
+ LifecycleHooks,
17
+ PostResponseErrorHook,
18
+ PostResponseSuccessHook,
19
+ PreRequestHook,
20
+ RateLimitOptions,
21
+ RetryOptions,
22
+ TimeoutOptions,
23
+ )
24
+
25
+ T = TypeVar("T")
26
+
27
+
28
+ class FetchBuilder:
29
+ """Builder class for creating configured fetch instances with retry,
30
+ rate limiting, and circuit breaking.
31
+
32
+ Provides a fluent interface for configuring fetch options.
33
+
34
+ Example::
35
+
36
+ builder = FetchBuilder()
37
+ builder = (
38
+ builder
39
+ .with_retry(RetryOptions(attempts=3))
40
+ .with_timeout(5000)
41
+ .with_rate_limit(RateLimitOptions(max_requests=10, window_ms=60000))
42
+ .with_headers({"Authorization": "Bearer token"})
43
+ )
44
+ response = await builder.fetch("https://api.example.com/data")
45
+ """
46
+
47
+ def __init__(self) -> None:
48
+ self._retry: RetryOptions | None = None
49
+ self._timeout: TimeoutOptions | None = None
50
+ self._rate_limit: RateLimitOptions | None = None
51
+ self._circuit_breaker: CircuitBreakerOptions | None = None
52
+ self._schema: type[BaseModel] | None = None
53
+ self._headers: dict[str, str] = {}
54
+ self._hooks: LifecycleHooks = LifecycleHooks()
55
+
56
+ def with_retry(self, options: RetryOptions | None = None) -> FetchBuilder:
57
+ """Configure retry behavior.
58
+
59
+ Args:
60
+ options: Retry configuration. Defaults to DEFAULT_RETRY_OPTIONS if None.
61
+
62
+ Returns:
63
+ The builder instance for method chaining.
64
+ """
65
+ self._retry = options if options is not None else DEFAULT_RETRY_OPTIONS
66
+ return self
67
+
68
+ def with_timeout(self, timeout_ms: float) -> FetchBuilder:
69
+ """Set the request timeout.
70
+
71
+ Args:
72
+ timeout_ms: Timeout duration in milliseconds.
73
+
74
+ Returns:
75
+ The builder instance for method chaining.
76
+ """
77
+ self._timeout = TimeoutOptions(timeout_ms=timeout_ms)
78
+ return self
79
+
80
+ def with_rate_limit(self, options: RateLimitOptions) -> FetchBuilder:
81
+ """Configure rate limiting.
82
+
83
+ Args:
84
+ options: Rate limit configuration.
85
+
86
+ Returns:
87
+ The builder instance for method chaining.
88
+ """
89
+ self._rate_limit = options
90
+ return self
91
+
92
+ def with_circuit_breaker(self, options: CircuitBreakerOptions) -> FetchBuilder:
93
+ """Configure circuit breaker behavior.
94
+
95
+ Args:
96
+ options: Circuit breaker configuration.
97
+
98
+ Returns:
99
+ The builder instance for method chaining.
100
+ """
101
+ self._circuit_breaker = options
102
+ return self
103
+
104
+ def with_schema(self, schema: type[BaseModel]) -> FetchBuilder:
105
+ """Set a Pydantic model for response validation.
106
+
107
+ Args:
108
+ schema: The Pydantic model class to validate against.
109
+
110
+ Returns:
111
+ The builder instance for method chaining.
112
+ """
113
+ self._schema = schema
114
+ return self
115
+
116
+ def with_headers(self, headers: dict[str, str]) -> FetchBuilder:
117
+ """Set default headers for all requests.
118
+
119
+ Args:
120
+ headers: Dictionary of header name-value pairs.
121
+
122
+ Returns:
123
+ The builder instance for method chaining.
124
+ """
125
+ self._headers.update(headers)
126
+ return self
127
+
128
+ def with_auth(self, token: str, scheme: str = "Bearer") -> FetchBuilder:
129
+ """Set an authorization header.
130
+
131
+ Args:
132
+ token: The authentication token.
133
+ scheme: The auth scheme (default: "Bearer").
134
+
135
+ Returns:
136
+ The builder instance for method chaining.
137
+ """
138
+ self._headers["Authorization"] = f"{scheme} {token}"
139
+ return self
140
+
141
+ def with_pre_request_hook(self, hook: PreRequestHook) -> FetchBuilder:
142
+ """Set a pre-request hook.
143
+
144
+ Args:
145
+ hook: Function called before each request.
146
+
147
+ Returns:
148
+ The builder instance for method chaining.
149
+ """
150
+ self._hooks.pre_request = hook
151
+ return self
152
+
153
+ def with_post_response_success_hook(self, hook: PostResponseSuccessHook) -> FetchBuilder:
154
+ """Set a post-response success hook.
155
+
156
+ Args:
157
+ hook: Function called after a successful response.
158
+
159
+ Returns:
160
+ The builder instance for method chaining.
161
+ """
162
+ self._hooks.post_response_success = hook
163
+ return self
164
+
165
+ def with_post_response_error_hook(self, hook: PostResponseErrorHook) -> FetchBuilder:
166
+ """Set a post-response error hook.
167
+
168
+ Args:
169
+ hook: Function called after a failed response.
170
+
171
+ Returns:
172
+ The builder instance for method chaining.
173
+ """
174
+ self._hooks.post_response_error = hook
175
+ return self
176
+
177
+ def build(self) -> FetchOptions:
178
+ """Build the FetchOptions from the current configuration.
179
+
180
+ Returns:
181
+ A FetchOptions instance with all configured settings.
182
+ """
183
+ container_options: FetchContainerOptions | None = None
184
+ if self._rate_limit or self._circuit_breaker:
185
+ container_options = FetchContainerOptions(
186
+ rate_limit=self._rate_limit,
187
+ circuit_breaker=self._circuit_breaker,
188
+ )
189
+
190
+ return FetchOptions(
191
+ headers=self._headers if self._headers else None,
192
+ retry=self._retry,
193
+ timeout=self._timeout,
194
+ schema=self._schema,
195
+ hooks=self._hooks,
196
+ container_options=container_options,
197
+ )
198
+
199
+ async def fetch(
200
+ self,
201
+ url: str,
202
+ method: str = "GET",
203
+ headers: dict[str, str] | None = None,
204
+ body: Any = None,
205
+ ) -> FetchResponse[Any]:
206
+ """Execute a request using the built configuration.
207
+
208
+ Args:
209
+ url: The URL to request.
210
+ method: HTTP method (default: "GET").
211
+ headers: Additional headers for this specific request.
212
+ body: Request body.
213
+
214
+ Returns:
215
+ A FetchResponse containing the parsed response data.
216
+ """
217
+ opts = self.build()
218
+ opts.method = method
219
+
220
+ # Merge per-request headers with builder headers
221
+ merged_headers = dict(self._headers) if self._headers else {}
222
+ if headers:
223
+ merged_headers.update(headers)
224
+ if merged_headers:
225
+ opts.headers = merged_headers
226
+
227
+ if body is not None:
228
+ opts.body = body
229
+
230
+ return await _fetch(url, opts)
@@ -0,0 +1,129 @@
1
+ """Circuit breaker implementation for the smooai-fetch client.
2
+
3
+ Implements a simple async-compatible circuit breaker without relying on
4
+ pybreaker's async support (which requires tornado).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from collections.abc import Awaitable, Callable
12
+ from enum import Enum
13
+ from typing import TypeVar
14
+
15
+ from smooai_fetch._errors import CircuitBreakerError
16
+ from smooai_fetch._types import CircuitBreakerOptions
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ class CircuitState(Enum):
22
+ """Possible states of the circuit breaker."""
23
+
24
+ CLOSED = "closed"
25
+ OPEN = "open"
26
+ HALF_OPEN = "half-open"
27
+
28
+
29
+ class CircuitBreaker:
30
+ """An async-compatible circuit breaker.
31
+
32
+ State transitions:
33
+ - CLOSED: Normal operation. Failures are counted.
34
+ - OPEN: Requests are rejected immediately. After timeout, transitions to HALF_OPEN.
35
+ - HALF_OPEN: A limited number of requests are allowed through. If they succeed
36
+ (reaching success_threshold), transitions to CLOSED. If one fails, transitions
37
+ back to OPEN.
38
+ """
39
+
40
+ def __init__(self, options: CircuitBreakerOptions) -> None:
41
+ self._failure_threshold = options.failure_threshold
42
+ self._success_threshold = options.success_threshold
43
+ self._timeout = options.timeout # seconds
44
+
45
+ self._state = CircuitState.CLOSED
46
+ self._failure_count = 0
47
+ self._success_count = 0
48
+ self._last_failure_time: float | None = None
49
+ self._lock = asyncio.Lock()
50
+
51
+ @property
52
+ def state(self) -> str:
53
+ """Current state of the circuit breaker as a string."""
54
+ # Check if we should transition from OPEN to HALF_OPEN
55
+ if self._state == CircuitState.OPEN and self._last_failure_time is not None:
56
+ elapsed = time.monotonic() - self._last_failure_time
57
+ if elapsed >= self._timeout:
58
+ return CircuitState.HALF_OPEN.value
59
+ return self._state.value
60
+
61
+ async def call(self, func: Callable[..., Awaitable[T]]) -> T:
62
+ """Execute an async function through the circuit breaker.
63
+
64
+ Args:
65
+ func: The async callable to execute.
66
+
67
+ Returns:
68
+ The result of the function.
69
+
70
+ Raises:
71
+ CircuitBreakerError: If the circuit is open and timeout has not elapsed.
72
+ """
73
+ async with self._lock:
74
+ current_state = self._get_state()
75
+
76
+ if current_state == CircuitState.OPEN:
77
+ raise CircuitBreakerError("Circuit breaker is open")
78
+
79
+ if current_state == CircuitState.HALF_OPEN:
80
+ # Allow the request through, but track carefully
81
+ pass
82
+
83
+ # Execute the function outside the lock
84
+ try:
85
+ result = await func()
86
+ except Exception:
87
+ async with self._lock:
88
+ self._record_failure()
89
+ raise
90
+
91
+ async with self._lock:
92
+ self._record_success()
93
+
94
+ return result
95
+
96
+ def _get_state(self) -> CircuitState:
97
+ """Get the current state, potentially transitioning OPEN -> HALF_OPEN."""
98
+ if self._state == CircuitState.OPEN and self._last_failure_time is not None:
99
+ elapsed = time.monotonic() - self._last_failure_time
100
+ if elapsed >= self._timeout:
101
+ self._state = CircuitState.HALF_OPEN
102
+ self._success_count = 0
103
+ return CircuitState.HALF_OPEN
104
+ return self._state
105
+
106
+ def _record_success(self) -> None:
107
+ """Record a successful call."""
108
+ if self._state == CircuitState.HALF_OPEN:
109
+ self._success_count += 1
110
+ if self._success_count >= self._success_threshold:
111
+ self._state = CircuitState.CLOSED
112
+ self._failure_count = 0
113
+ self._success_count = 0
114
+ elif self._state == CircuitState.CLOSED:
115
+ # Reset failure count on success in closed state
116
+ self._failure_count = 0
117
+
118
+ def _record_failure(self) -> None:
119
+ """Record a failed call."""
120
+ if self._state == CircuitState.HALF_OPEN:
121
+ # Any failure in half-open goes back to open
122
+ self._state = CircuitState.OPEN
123
+ self._last_failure_time = time.monotonic()
124
+ self._success_count = 0
125
+ elif self._state == CircuitState.CLOSED:
126
+ self._failure_count += 1
127
+ if self._failure_count >= self._failure_threshold:
128
+ self._state = CircuitState.OPEN
129
+ self._last_failure_time = time.monotonic()