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.
- smooai_fetch-2.1.2/.gitignore +57 -0
- smooai_fetch-2.1.2/PKG-INFO +18 -0
- smooai_fetch-2.1.2/pyproject.toml +62 -0
- smooai_fetch-2.1.2/src/smooai_fetch/__init__.py +91 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_builder.py +230 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_circuit_breaker.py +129 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_client.py +302 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_defaults.py +19 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_errors.py +124 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_hooks.py +18 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_rate_limit.py +55 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_response.py +52 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_retry.py +118 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_timeout.py +20 -0
- smooai_fetch-2.1.2/src/smooai_fetch/_types.py +131 -0
- smooai_fetch-2.1.2/src/smooai_fetch/py.typed +0 -0
- smooai_fetch-2.1.2/tests/__init__.py +0 -0
- smooai_fetch-2.1.2/tests/conftest.py +9 -0
- smooai_fetch-2.1.2/tests/test_basic.py +75 -0
- smooai_fetch-2.1.2/tests/test_builder.py +216 -0
- smooai_fetch-2.1.2/tests/test_circuit_breaker.py +161 -0
- smooai_fetch-2.1.2/tests/test_fetch.py +304 -0
- smooai_fetch-2.1.2/tests/test_hooks.py +232 -0
- smooai_fetch-2.1.2/tests/test_integration.py +323 -0
- smooai_fetch-2.1.2/tests/test_rate_limit.py +117 -0
- smooai_fetch-2.1.2/tests/test_retry.py +201 -0
- smooai_fetch-2.1.2/tests/test_schema.py +235 -0
- smooai_fetch-2.1.2/tests/test_timeout.py +79 -0
- smooai_fetch-2.1.2/uv.lock +429 -0
|
@@ -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()
|