swarmauri_transport_retry 0.1.0.dev1__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,13 @@
1
+ Copyright 2026 Jacob Stewart
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: swarmauri_transport_retry
3
+ Version: 0.1.0.dev1
4
+ Summary: Policy-driven synchronous, asynchronous, and streaming HTTP retries for Swarmauri clients.
5
+ License: Copyright 2026 Jacob Stewart
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
18
+ License-File: LICENSE
19
+ Keywords: swarmauri,http,retry,backoff,transport,async
20
+ Author: Jacob Stewart
21
+ Author-email: jacob@swarmauri.com
22
+ Requires-Python: >=3.10,<3.13
23
+ Classifier: Development Status :: 1 - Planning
24
+ Classifier: License :: OSI Approved :: Apache Software License
25
+ Classifier: Programming Language :: Python :: 3.10
26
+ Classifier: Programming Language :: Python :: 3.11
27
+ Classifier: Programming Language :: Python :: 3.12
28
+ Requires-Dist: swarmauri_core (>=0.11.0.dev5)
29
+ Description-Content-Type: text/markdown
30
+
31
+ ![Swarmauri Logo](https://github.com/swarmauri/swarmauri-sdk/blob/3d4d1cfa949399d7019ae9d8f296afba773dfb7f/assets/swarmauri.brand.theme.svg)
32
+
33
+ [![PyPI version](https://img.shields.io/pypi/v/swarmauri_transport_retry.svg)](https://pypi.org/project/swarmauri_transport_retry/) [![Python](https://img.shields.io/pypi/pyversions/swarmauri_transport_retry.svg)](https://pypi.org/project/swarmauri_transport_retry/) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) [![Downloads](https://static.pepy.tech/badge/swarmauri_transport_retry)](https://pepy.tech/project/swarmauri_transport_retry) [![Hits](https://hits.sh/github.com/swarmauri/swarmauri-sdk.svg?path=pkgs%2Fstandards%2Fswarmauri_transport_retry)](https://hits.sh/github.com/swarmauri/swarmauri-sdk/)
34
+
35
+ # swarmauri_transport_retry
36
+
37
+ Policy-driven retries for any client implementing Swarmauri's `IHttpClient` contract.
38
+
39
+ ## Features
40
+
41
+ - Bounded exponential backoff with optional jitter.
42
+ - `Retry-After` support for seconds and HTTP dates.
43
+ - Separate policy decisions from sync, async, and streaming execution.
44
+ - Protect non-idempotent methods from replay by default.
45
+ - Wrap any Swarmauri HTTP client without depending on its HTTP library.
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ uv add swarmauri_transport_retry
51
+ ```
52
+
53
+ ```bash
54
+ pip install swarmauri_transport_retry
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```python
60
+ from swarmauri_transport_httpx import HttpxClient
61
+ from swarmauri_transport_retry import (
62
+ ExponentialBackoffRetryPolicy,
63
+ RetryingHttpClient,
64
+ )
65
+
66
+ client = RetryingHttpClient(
67
+ client=HttpxClient(base_url="https://api.example.com"),
68
+ policy=ExponentialBackoffRetryPolicy(max_attempts=3),
69
+ )
70
+ response = client.request("GET", "/models")
71
+ response.raise_for_status()
72
+ ```
73
+
74
+ The retry policy controls replay safety and delay. The wrapped transport remains responsible for moving HTTP requests and responses.
75
+
@@ -0,0 +1,44 @@
1
+ ![Swarmauri Logo](https://github.com/swarmauri/swarmauri-sdk/blob/3d4d1cfa949399d7019ae9d8f296afba773dfb7f/assets/swarmauri.brand.theme.svg)
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/swarmauri_transport_retry.svg)](https://pypi.org/project/swarmauri_transport_retry/) [![Python](https://img.shields.io/pypi/pyversions/swarmauri_transport_retry.svg)](https://pypi.org/project/swarmauri_transport_retry/) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) [![Downloads](https://static.pepy.tech/badge/swarmauri_transport_retry)](https://pepy.tech/project/swarmauri_transport_retry) [![Hits](https://hits.sh/github.com/swarmauri/swarmauri-sdk.svg?path=pkgs%2Fstandards%2Fswarmauri_transport_retry)](https://hits.sh/github.com/swarmauri/swarmauri-sdk/)
4
+
5
+ # swarmauri_transport_retry
6
+
7
+ Policy-driven retries for any client implementing Swarmauri's `IHttpClient` contract.
8
+
9
+ ## Features
10
+
11
+ - Bounded exponential backoff with optional jitter.
12
+ - `Retry-After` support for seconds and HTTP dates.
13
+ - Separate policy decisions from sync, async, and streaming execution.
14
+ - Protect non-idempotent methods from replay by default.
15
+ - Wrap any Swarmauri HTTP client without depending on its HTTP library.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ uv add swarmauri_transport_retry
21
+ ```
22
+
23
+ ```bash
24
+ pip install swarmauri_transport_retry
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```python
30
+ from swarmauri_transport_httpx import HttpxClient
31
+ from swarmauri_transport_retry import (
32
+ ExponentialBackoffRetryPolicy,
33
+ RetryingHttpClient,
34
+ )
35
+
36
+ client = RetryingHttpClient(
37
+ client=HttpxClient(base_url="https://api.example.com"),
38
+ policy=ExponentialBackoffRetryPolicy(max_attempts=3),
39
+ )
40
+ response = client.request("GET", "/models")
41
+ response.raise_for_status()
42
+ ```
43
+
44
+ The retry policy controls replay safety and delay. The wrapped transport remains responsible for moving HTTP requests and responses.
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "swarmauri_transport_retry"
3
+ version = "0.1.0.dev1"
4
+ description = "Policy-driven synchronous, asynchronous, and streaming HTTP retries for Swarmauri clients."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10,<3.13"
7
+ authors = [{ name = "Jacob Stewart", email = "jacob@swarmauri.com" }]
8
+ license = { file = "LICENSE" }
9
+ dependencies = ["swarmauri_core>=0.11.0.dev5"]
10
+ classifiers = [
11
+ "Development Status :: 1 - Planning",
12
+ "License :: OSI Approved :: Apache Software License",
13
+ "Programming Language :: Python :: 3.10",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ ]
17
+ keywords = ["swarmauri", "http", "retry", "backoff", "transport", "async"]
18
+
19
+ [tool.uv.sources]
20
+ swarmauri_core = { workspace = true }
21
+
22
+ [project.entry-points."swarmauri.transports"]
23
+ RetryingHttpClient = "swarmauri_transport_retry:RetryingHttpClient"
24
+
25
+ [build-system]
26
+ requires = ["poetry-core>=1.8.0"]
27
+ build-backend = "poetry.core.masonry.api"
28
+
29
+ [dependency-groups]
30
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "ruff>=0.9.9"]
31
+
32
+ [tool.ruff]
33
+ extend = "../../pyproject.toml"
34
+
35
+ [tool.ruff.lint]
36
+ ignore = ["N999"]
@@ -0,0 +1,113 @@
1
+ """Concrete exponential-backoff policy for HTTP operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections.abc import Collection
7
+ from datetime import datetime, timezone
8
+ from email.utils import parsedate_to_datetime
9
+ from typing import Literal
10
+
11
+ from swarmauri_core.transports.i_http_response import IHttpResponse
12
+ from swarmauri_core.transports.i_http_retry_policy import IHttpRetryPolicy
13
+
14
+
15
+ class ExponentialBackoffRetryPolicy(IHttpRetryPolicy):
16
+ """Retry safe HTTP operations with bounded exponential backoff."""
17
+
18
+ type: Literal["ExponentialBackoffRetryPolicy"] = (
19
+ "ExponentialBackoffRetryPolicy"
20
+ )
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ max_attempts: int = 3,
26
+ retryable_status_codes: Collection[int] = (429, 500, 502, 503, 504),
27
+ retryable_methods: Collection[str] = (
28
+ "GET",
29
+ "HEAD",
30
+ "OPTIONS",
31
+ "PUT",
32
+ "DELETE",
33
+ ),
34
+ base_delay: float = 1.0,
35
+ maximum_delay: float = 30.0,
36
+ respect_retry_after: bool = True,
37
+ retry_request_errors: bool = True,
38
+ jitter_ratio: float = 0.0,
39
+ ) -> None:
40
+ if max_attempts < 1:
41
+ raise ValueError("max_attempts must be at least 1")
42
+ if base_delay < 0 or maximum_delay < 0:
43
+ raise ValueError("retry delays cannot be negative")
44
+ if not 0 <= jitter_ratio <= 1:
45
+ raise ValueError("jitter_ratio must be between 0 and 1")
46
+ self.max_attempts = max_attempts
47
+ self.retryable_status_codes = frozenset(retryable_status_codes)
48
+ self.retryable_methods = frozenset(
49
+ method.upper() for method in retryable_methods
50
+ )
51
+ self.base_delay = base_delay
52
+ self.maximum_delay = maximum_delay
53
+ self.respect_retry_after = respect_retry_after
54
+ self.retry_request_errors = retry_request_errors
55
+ self.jitter_ratio = jitter_ratio
56
+
57
+ def should_retry(
58
+ self,
59
+ *,
60
+ method: str,
61
+ attempt: int,
62
+ response: IHttpResponse | None = None,
63
+ error: Exception | None = None,
64
+ ) -> bool:
65
+ if attempt >= self.max_attempts:
66
+ return False
67
+ if method.upper() not in self.retryable_methods:
68
+ return False
69
+ if error is not None:
70
+ return self.retry_request_errors
71
+ return (
72
+ response is not None
73
+ and response.status_code in self.retryable_status_codes
74
+ )
75
+
76
+ def delay_seconds(
77
+ self,
78
+ *,
79
+ attempt: int,
80
+ response: IHttpResponse | None = None,
81
+ ) -> float:
82
+ retry_after = self._retry_after_seconds(response)
83
+ if retry_after is not None:
84
+ delay = retry_after
85
+ else:
86
+ delay = self.base_delay * (2 ** max(attempt - 1, 0))
87
+ delay = min(delay, self.maximum_delay)
88
+ if self.jitter_ratio:
89
+ spread = delay * self.jitter_ratio
90
+ delay = random.uniform(max(0.0, delay - spread), delay + spread)
91
+ return max(0.0, min(delay, self.maximum_delay))
92
+
93
+ def _retry_after_seconds(
94
+ self, response: IHttpResponse | None
95
+ ) -> float | None:
96
+ if not self.respect_retry_after or response is None:
97
+ return None
98
+ value = response.headers.get("Retry-After")
99
+ if not value:
100
+ return None
101
+ try:
102
+ return max(float(value), 0.0)
103
+ except ValueError:
104
+ try:
105
+ retry_at = parsedate_to_datetime(value)
106
+ except (TypeError, ValueError):
107
+ return None
108
+ if retry_at.tzinfo is None:
109
+ retry_at = retry_at.replace(tzinfo=timezone.utc)
110
+ return max(
111
+ (retry_at - datetime.now(timezone.utc)).total_seconds(),
112
+ 0.0,
113
+ )
@@ -0,0 +1,208 @@
1
+ """HTTP client decorator that executes concrete retry policies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import AsyncIterator, Iterator, Mapping
8
+ from contextlib import asynccontextmanager, contextmanager
9
+ from typing import Any, Callable
10
+
11
+ from swarmauri_core.transports.http_errors import HttpRequestError
12
+ from swarmauri_core.transports.i_http_client import IHttpClient
13
+ from swarmauri_core.transports.i_http_response import IHttpResponse
14
+ from swarmauri_core.transports.i_http_retry_policy import IHttpRetryPolicy
15
+ from swarmauri_core.transports.i_http_stream_response import (
16
+ IAsyncHttpStreamResponse,
17
+ IHttpStreamResponse,
18
+ )
19
+ from swarmauri_core.transports.i_managed_http_client import IManagedHttpClient
20
+
21
+
22
+ class RetryingHttpClient(IManagedHttpClient):
23
+ """Decorate any Swarmauri HTTP client with an explicit retry policy."""
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ client: IHttpClient,
29
+ policy: IHttpRetryPolicy,
30
+ sleep: Callable[[float], None] = time.sleep,
31
+ async_sleep: Callable[[float], Any] = asyncio.sleep,
32
+ own_client: bool = False,
33
+ ) -> None:
34
+ self.client = client
35
+ self.policy = policy
36
+ self._sleep = sleep
37
+ self._async_sleep = async_sleep
38
+ self.own_client = own_client
39
+
40
+ def request(
41
+ self,
42
+ method: str,
43
+ path: str,
44
+ *,
45
+ headers: Mapping[str, str] | None = None,
46
+ body: bytes = b"",
47
+ **kwargs: Any,
48
+ ) -> IHttpResponse:
49
+ attempt = 1
50
+ while True:
51
+ try:
52
+ response = self.client.request(
53
+ method,
54
+ path,
55
+ headers=headers,
56
+ body=body,
57
+ **kwargs,
58
+ )
59
+ except HttpRequestError as error:
60
+ if not self.policy.should_retry(
61
+ method=method, attempt=attempt, error=error
62
+ ):
63
+ raise
64
+ self._sleep(self.policy.delay_seconds(attempt=attempt))
65
+ else:
66
+ if not self.policy.should_retry(
67
+ method=method, attempt=attempt, response=response
68
+ ):
69
+ return response
70
+ self._sleep(
71
+ self.policy.delay_seconds(
72
+ attempt=attempt, response=response
73
+ )
74
+ )
75
+ attempt += 1
76
+
77
+ async def arequest(
78
+ self,
79
+ method: str,
80
+ path: str,
81
+ *,
82
+ headers: Mapping[str, str] | None = None,
83
+ body: bytes = b"",
84
+ **kwargs: Any,
85
+ ) -> IHttpResponse:
86
+ attempt = 1
87
+ while True:
88
+ try:
89
+ response = await self.client.arequest(
90
+ method,
91
+ path,
92
+ headers=headers,
93
+ body=body,
94
+ **kwargs,
95
+ )
96
+ except HttpRequestError as error:
97
+ if not self.policy.should_retry(
98
+ method=method, attempt=attempt, error=error
99
+ ):
100
+ raise
101
+ await self._async_sleep(
102
+ self.policy.delay_seconds(attempt=attempt)
103
+ )
104
+ else:
105
+ if not self.policy.should_retry(
106
+ method=method, attempt=attempt, response=response
107
+ ):
108
+ return response
109
+ await self._async_sleep(
110
+ self.policy.delay_seconds(
111
+ attempt=attempt, response=response
112
+ )
113
+ )
114
+ attempt += 1
115
+
116
+ @contextmanager
117
+ def stream(
118
+ self,
119
+ method: str,
120
+ path: str,
121
+ *,
122
+ headers: Mapping[str, str] | None = None,
123
+ body: bytes = b"",
124
+ **kwargs: Any,
125
+ ) -> Iterator[IHttpStreamResponse]:
126
+ attempt = 1
127
+ while True:
128
+ try:
129
+ with self.client.stream(
130
+ method,
131
+ path,
132
+ headers=headers,
133
+ body=body,
134
+ **kwargs,
135
+ ) as response:
136
+ if not self.policy.should_retry(
137
+ method=method, attempt=attempt, response=response
138
+ ):
139
+ yield response
140
+ return
141
+ delay = self.policy.delay_seconds(
142
+ attempt=attempt, response=response
143
+ )
144
+ except HttpRequestError as error:
145
+ if not self.policy.should_retry(
146
+ method=method, attempt=attempt, error=error
147
+ ):
148
+ raise
149
+ delay = self.policy.delay_seconds(attempt=attempt)
150
+ self._sleep(delay)
151
+ attempt += 1
152
+
153
+ @asynccontextmanager
154
+ async def astream(
155
+ self,
156
+ method: str,
157
+ path: str,
158
+ *,
159
+ headers: Mapping[str, str] | None = None,
160
+ body: bytes = b"",
161
+ **kwargs: Any,
162
+ ) -> AsyncIterator[IAsyncHttpStreamResponse]:
163
+ attempt = 1
164
+ while True:
165
+ try:
166
+ async with self.client.astream(
167
+ method,
168
+ path,
169
+ headers=headers,
170
+ body=body,
171
+ **kwargs,
172
+ ) as response:
173
+ if not self.policy.should_retry(
174
+ method=method, attempt=attempt, response=response
175
+ ):
176
+ yield response
177
+ return
178
+ delay = self.policy.delay_seconds(
179
+ attempt=attempt, response=response
180
+ )
181
+ except HttpRequestError as error:
182
+ if not self.policy.should_retry(
183
+ method=method, attempt=attempt, error=error
184
+ ):
185
+ raise
186
+ delay = self.policy.delay_seconds(attempt=attempt)
187
+ await self._async_sleep(delay)
188
+ attempt += 1
189
+
190
+ def close(self) -> None:
191
+ if self.own_client and isinstance(self.client, IManagedHttpClient):
192
+ self.client.close()
193
+
194
+ async def aclose(self) -> None:
195
+ if self.own_client and isinstance(self.client, IManagedHttpClient):
196
+ await self.client.aclose()
197
+
198
+ def __enter__(self) -> "RetryingHttpClient":
199
+ return self
200
+
201
+ def __exit__(self, *args: Any) -> None:
202
+ self.close()
203
+
204
+ async def __aenter__(self) -> "RetryingHttpClient":
205
+ return self
206
+
207
+ async def __aexit__(self, *args: Any) -> None:
208
+ await self.aclose()
@@ -0,0 +1,4 @@
1
+ from .ExponentialBackoffRetryPolicy import ExponentialBackoffRetryPolicy
2
+ from .RetryingHttpClient import RetryingHttpClient
3
+
4
+ __all__ = ["ExponentialBackoffRetryPolicy", "RetryingHttpClient"]