sturdytools 0.1.0__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.
- sturdytools/__init__.py +17 -0
- sturdytools/breaker.py +88 -0
- sturdytools/core.py +269 -0
- sturdytools/errors.py +22 -0
- sturdytools/ratelimit.py +99 -0
- sturdytools-0.1.0.dist-info/METADATA +193 -0
- sturdytools-0.1.0.dist-info/RECORD +10 -0
- sturdytools-0.1.0.dist-info/WHEEL +5 -0
- sturdytools-0.1.0.dist-info/licenses/LICENSE +21 -0
- sturdytools-0.1.0.dist-info/top_level.txt +1 -0
sturdytools/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Retries, timeouts, rate limits and circuit breakers for AI agent tools."""
|
|
2
|
+
|
|
3
|
+
from .breaker import CircuitBreaker
|
|
4
|
+
from .core import error_message, sturdy
|
|
5
|
+
from .errors import CircuitOpenError, ToolTimeout
|
|
6
|
+
from .ratelimit import RateLimiter
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"sturdy",
|
|
10
|
+
"CircuitBreaker",
|
|
11
|
+
"RateLimiter",
|
|
12
|
+
"ToolTimeout",
|
|
13
|
+
"CircuitOpenError",
|
|
14
|
+
"error_message",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
sturdytools/breaker.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""A small circuit breaker.
|
|
2
|
+
|
|
3
|
+
After ``failure_threshold`` failures in a row the breaker opens and calls are
|
|
4
|
+
rejected right away, without touching the failing service. Once
|
|
5
|
+
``reset_after`` seconds have passed, a single trial call is let through. If it
|
|
6
|
+
works the breaker closes again; if it fails the breaker stays open for another
|
|
7
|
+
``reset_after`` seconds.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from .errors import CircuitOpenError
|
|
17
|
+
|
|
18
|
+
CLOSED = "closed"
|
|
19
|
+
OPEN = "open"
|
|
20
|
+
HALF_OPEN = "half_open"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CircuitBreaker:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
failure_threshold: int = 5,
|
|
27
|
+
reset_after: float = 30.0,
|
|
28
|
+
clock: Callable[[], float] = time.monotonic,
|
|
29
|
+
):
|
|
30
|
+
if failure_threshold < 1:
|
|
31
|
+
raise ValueError("failure_threshold must be at least 1")
|
|
32
|
+
if reset_after <= 0:
|
|
33
|
+
raise ValueError("reset_after must be positive")
|
|
34
|
+
self.failure_threshold = failure_threshold
|
|
35
|
+
self.reset_after = reset_after
|
|
36
|
+
self._clock = clock
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._state = CLOSED
|
|
39
|
+
self._failures = 0
|
|
40
|
+
self._opened_at = 0.0
|
|
41
|
+
self._trial_running = False
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def state(self) -> str:
|
|
45
|
+
return self._state
|
|
46
|
+
|
|
47
|
+
def before_call(self) -> None:
|
|
48
|
+
"""Raise CircuitOpenError if the call should not go through."""
|
|
49
|
+
with self._lock:
|
|
50
|
+
if self._state == CLOSED:
|
|
51
|
+
return
|
|
52
|
+
waited = self._clock() - self._opened_at
|
|
53
|
+
if self._state == OPEN and waited >= self.reset_after:
|
|
54
|
+
self._state = HALF_OPEN
|
|
55
|
+
if self._state == HALF_OPEN and not self._trial_running:
|
|
56
|
+
self._trial_running = True
|
|
57
|
+
return
|
|
58
|
+
raise CircuitOpenError(retry_in=max(0.0, self.reset_after - waited))
|
|
59
|
+
|
|
60
|
+
def record_success(self) -> None:
|
|
61
|
+
with self._lock:
|
|
62
|
+
self._state = CLOSED
|
|
63
|
+
self._failures = 0
|
|
64
|
+
self._trial_running = False
|
|
65
|
+
|
|
66
|
+
def record_failure(self) -> None:
|
|
67
|
+
with self._lock:
|
|
68
|
+
self._trial_running = False
|
|
69
|
+
if self._state == HALF_OPEN:
|
|
70
|
+
self._open()
|
|
71
|
+
return
|
|
72
|
+
self._failures += 1
|
|
73
|
+
if self._failures >= self.failure_threshold:
|
|
74
|
+
self._open()
|
|
75
|
+
|
|
76
|
+
def reset(self) -> None:
|
|
77
|
+
"""Close the breaker by hand, for example after fixing the service."""
|
|
78
|
+
self.record_success()
|
|
79
|
+
|
|
80
|
+
def _open(self) -> None:
|
|
81
|
+
self._state = OPEN
|
|
82
|
+
self._opened_at = self._clock()
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> str:
|
|
85
|
+
return (
|
|
86
|
+
f"CircuitBreaker(state={self._state!r}, failures={self._failures}, "
|
|
87
|
+
f"failure_threshold={self.failure_threshold}, reset_after={self.reset_after:g})"
|
|
88
|
+
)
|
sturdytools/core.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""The ``sturdy`` decorator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextvars
|
|
7
|
+
import functools
|
|
8
|
+
import inspect
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Callable, Optional, Tuple, Type, Union
|
|
14
|
+
|
|
15
|
+
from .breaker import CircuitBreaker
|
|
16
|
+
from .errors import CircuitOpenError, ToolTimeout
|
|
17
|
+
from .ratelimit import RateLimiter, as_limiter
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("sturdytools")
|
|
20
|
+
|
|
21
|
+
_MISSING = object()
|
|
22
|
+
|
|
23
|
+
ErrorTypes = Union[Type[BaseException], Tuple[Type[BaseException], ...]]
|
|
24
|
+
OnError = Union[str, Callable[[str, BaseException, int], Any]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def error_message(tool_name: str, exc: BaseException, attempts: int) -> str:
|
|
28
|
+
"""Turn a failure into a short message a language model can act on."""
|
|
29
|
+
if isinstance(exc, CircuitOpenError):
|
|
30
|
+
return (
|
|
31
|
+
f"Error: the tool '{tool_name}' is paused after repeated failures. "
|
|
32
|
+
f"It can be tried again in about {exc.retry_in:.0f} seconds. "
|
|
33
|
+
"Use another approach or tell the user it is unavailable."
|
|
34
|
+
)
|
|
35
|
+
tries = f"{attempts} attempt" + ("" if attempts == 1 else "s")
|
|
36
|
+
return (
|
|
37
|
+
f"Error: the tool '{tool_name}' failed after {tries} "
|
|
38
|
+
f"({type(exc).__name__}: {exc}). "
|
|
39
|
+
"Check the arguments, try a different approach, or tell the user it is unavailable."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Policy:
|
|
44
|
+
"""Everything the sync and async wrappers share."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
name: str,
|
|
49
|
+
retries: int,
|
|
50
|
+
retry_on: ErrorTypes,
|
|
51
|
+
backoff: float,
|
|
52
|
+
max_backoff: float,
|
|
53
|
+
fallback: Any,
|
|
54
|
+
on_error: OnError,
|
|
55
|
+
on_retry: Optional[Callable[[int, BaseException, float], Any]],
|
|
56
|
+
):
|
|
57
|
+
self.name = name
|
|
58
|
+
self.retries = retries
|
|
59
|
+
self.retry_on = retry_on
|
|
60
|
+
self.backoff = backoff
|
|
61
|
+
self.max_backoff = max_backoff
|
|
62
|
+
self.fallback = fallback
|
|
63
|
+
self.on_error = on_error
|
|
64
|
+
self.on_retry = on_retry
|
|
65
|
+
|
|
66
|
+
def is_transient(self, exc: BaseException) -> bool:
|
|
67
|
+
return isinstance(exc, self.retry_on)
|
|
68
|
+
|
|
69
|
+
def retry_delay(self, attempt: int, exc: BaseException) -> Optional[float]:
|
|
70
|
+
"""Seconds to wait before the next attempt, or None to stop."""
|
|
71
|
+
if attempt > self.retries or not self.is_transient(exc):
|
|
72
|
+
return None
|
|
73
|
+
ceiling = min(self.max_backoff, self.backoff * (2 ** (attempt - 1)))
|
|
74
|
+
delay = ceiling / 2 + random.uniform(0, ceiling / 2)
|
|
75
|
+
logger.warning(
|
|
76
|
+
"tool %r failed on attempt %d (%s: %s), retrying in %.2fs",
|
|
77
|
+
self.name, attempt, type(exc).__name__, exc, delay,
|
|
78
|
+
)
|
|
79
|
+
if self.on_retry is not None:
|
|
80
|
+
self.on_retry(attempt, exc, delay)
|
|
81
|
+
return delay
|
|
82
|
+
|
|
83
|
+
def give_up(self, exc: BaseException, attempts: int, args: tuple, kwargs: dict) -> Any:
|
|
84
|
+
"""Final failure: use the fallback, return a message, or raise."""
|
|
85
|
+
if self.fallback is not _MISSING:
|
|
86
|
+
logger.warning("tool %r failed, using fallback (%s: %s)", self.name, type(exc).__name__, exc)
|
|
87
|
+
if callable(self.fallback):
|
|
88
|
+
return self.fallback(*args, **kwargs)
|
|
89
|
+
return self.fallback
|
|
90
|
+
if self.on_error == "message":
|
|
91
|
+
return error_message(self.name, exc, attempts)
|
|
92
|
+
if callable(self.on_error):
|
|
93
|
+
return self.on_error(self.name, exc, attempts)
|
|
94
|
+
raise exc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _update_breaker(breaker: Optional[CircuitBreaker], policy: _Policy, exc: BaseException) -> None:
|
|
98
|
+
if breaker is None:
|
|
99
|
+
return
|
|
100
|
+
# Only temporary errors say the service is unhealthy. A bad argument from
|
|
101
|
+
# the model, for example, means the service answered, so it counts as up.
|
|
102
|
+
if policy.is_transient(exc):
|
|
103
|
+
breaker.record_failure()
|
|
104
|
+
else:
|
|
105
|
+
breaker.record_success()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _run_with_timeout(fn: Callable, args: tuple, kwargs: dict, timeout: Optional[float], name: str) -> Any:
|
|
109
|
+
if timeout is None:
|
|
110
|
+
return fn(*args, **kwargs)
|
|
111
|
+
|
|
112
|
+
# Python cannot stop a running thread, so a call that times out keeps
|
|
113
|
+
# running in the background. The thread is a daemon so it never blocks exit.
|
|
114
|
+
ctx = contextvars.copy_context()
|
|
115
|
+
box: dict = {}
|
|
116
|
+
|
|
117
|
+
def target() -> None:
|
|
118
|
+
try:
|
|
119
|
+
box["value"] = ctx.run(fn, *args, **kwargs)
|
|
120
|
+
except BaseException as exc: # handed back to the caller below
|
|
121
|
+
box["error"] = exc
|
|
122
|
+
|
|
123
|
+
worker = threading.Thread(target=target, name=f"sturdytools-{name}", daemon=True)
|
|
124
|
+
worker.start()
|
|
125
|
+
worker.join(timeout)
|
|
126
|
+
if worker.is_alive():
|
|
127
|
+
raise ToolTimeout(name, timeout)
|
|
128
|
+
if "error" in box:
|
|
129
|
+
raise box["error"]
|
|
130
|
+
return box["value"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _await_with_timeout(coro: Any, timeout: Optional[float], name: str) -> Any:
|
|
134
|
+
if timeout is None:
|
|
135
|
+
return await coro
|
|
136
|
+
task = asyncio.ensure_future(coro)
|
|
137
|
+
try:
|
|
138
|
+
done, _ = await asyncio.wait({task}, timeout=timeout)
|
|
139
|
+
except asyncio.CancelledError:
|
|
140
|
+
task.cancel()
|
|
141
|
+
raise
|
|
142
|
+
if not done:
|
|
143
|
+
task.cancel()
|
|
144
|
+
raise ToolTimeout(name, timeout)
|
|
145
|
+
return task.result()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def sturdy(
|
|
149
|
+
func: Optional[Callable] = None,
|
|
150
|
+
*,
|
|
151
|
+
timeout: Optional[float] = None,
|
|
152
|
+
retries: int = 0,
|
|
153
|
+
retry_on: ErrorTypes = (Exception,),
|
|
154
|
+
backoff: float = 0.5,
|
|
155
|
+
max_backoff: float = 8.0,
|
|
156
|
+
rate_limit: Union[str, RateLimiter, None] = None,
|
|
157
|
+
breaker: Union[CircuitBreaker, bool, None] = None,
|
|
158
|
+
fallback: Any = _MISSING,
|
|
159
|
+
on_error: OnError = "raise",
|
|
160
|
+
on_retry: Optional[Callable[[int, BaseException, float], Any]] = None,
|
|
161
|
+
) -> Any:
|
|
162
|
+
"""Make a tool function survive slow, flaky or failing dependencies.
|
|
163
|
+
|
|
164
|
+
Works on plain and async functions and keeps the original name, docstring
|
|
165
|
+
and signature, so agent frameworks still see the same tool.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
timeout: seconds before one attempt is abandoned with ToolTimeout.
|
|
169
|
+
retries: extra attempts after the first one fails.
|
|
170
|
+
retry_on: errors that count as temporary. Only these are retried and
|
|
171
|
+
counted by the breaker. Anything else goes straight to the final step.
|
|
172
|
+
backoff: base wait before the first retry. It doubles each time, with
|
|
173
|
+
random jitter, up to ``max_backoff``.
|
|
174
|
+
rate_limit: ``"5/s"``, ``"60/min"`` or a shared RateLimiter.
|
|
175
|
+
breaker: a CircuitBreaker (share one across tools that use the same
|
|
176
|
+
service), or True for a default one per tool.
|
|
177
|
+
fallback: value to return when the tool gives up. If callable it is
|
|
178
|
+
called with the tool's arguments.
|
|
179
|
+
on_error: what to do when the tool gives up and there is no fallback.
|
|
180
|
+
``"raise"`` re-raises the error, ``"message"`` returns a short error
|
|
181
|
+
text the model can read, and a callable ``(name, error, attempts)``
|
|
182
|
+
returns whatever you like.
|
|
183
|
+
on_retry: called as ``(attempt, error, delay)`` before each retry.
|
|
184
|
+
"""
|
|
185
|
+
if retries < 0:
|
|
186
|
+
raise ValueError("retries must be zero or more")
|
|
187
|
+
if timeout is not None and timeout <= 0:
|
|
188
|
+
raise ValueError("timeout must be positive")
|
|
189
|
+
if backoff < 0 or max_backoff < 0:
|
|
190
|
+
raise ValueError("backoff values must be zero or more")
|
|
191
|
+
if not (on_error in ("raise", "message") or callable(on_error)):
|
|
192
|
+
raise ValueError("on_error must be 'raise', 'message' or a callable")
|
|
193
|
+
|
|
194
|
+
limiter = as_limiter(rate_limit)
|
|
195
|
+
if breaker is True:
|
|
196
|
+
breaker = CircuitBreaker()
|
|
197
|
+
elif breaker is False:
|
|
198
|
+
breaker = None
|
|
199
|
+
|
|
200
|
+
def decorate(fn: Callable) -> Callable:
|
|
201
|
+
name = getattr(fn, "__name__", "tool")
|
|
202
|
+
policy = _Policy(name, retries, retry_on, backoff, max_backoff, fallback, on_error, on_retry)
|
|
203
|
+
|
|
204
|
+
if inspect.iscoroutinefunction(fn):
|
|
205
|
+
|
|
206
|
+
@functools.wraps(fn)
|
|
207
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
208
|
+
attempt = 0
|
|
209
|
+
while True:
|
|
210
|
+
try:
|
|
211
|
+
if breaker is not None:
|
|
212
|
+
breaker.before_call()
|
|
213
|
+
except CircuitOpenError as exc:
|
|
214
|
+
result = policy.give_up(exc, attempt, args, kwargs)
|
|
215
|
+
return await result if inspect.isawaitable(result) else result
|
|
216
|
+
attempt += 1
|
|
217
|
+
if limiter is not None:
|
|
218
|
+
await limiter.acquire_async()
|
|
219
|
+
try:
|
|
220
|
+
value = await _await_with_timeout(fn(*args, **kwargs), timeout, name)
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
_update_breaker(breaker, policy, exc)
|
|
223
|
+
delay = policy.retry_delay(attempt, exc)
|
|
224
|
+
if delay is not None:
|
|
225
|
+
await asyncio.sleep(delay)
|
|
226
|
+
continue
|
|
227
|
+
result = policy.give_up(exc, attempt, args, kwargs)
|
|
228
|
+
return await result if inspect.isawaitable(result) else result
|
|
229
|
+
if breaker is not None:
|
|
230
|
+
breaker.record_success()
|
|
231
|
+
return value
|
|
232
|
+
|
|
233
|
+
wrapper = async_wrapper
|
|
234
|
+
else:
|
|
235
|
+
|
|
236
|
+
@functools.wraps(fn)
|
|
237
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
238
|
+
attempt = 0
|
|
239
|
+
while True:
|
|
240
|
+
try:
|
|
241
|
+
if breaker is not None:
|
|
242
|
+
breaker.before_call()
|
|
243
|
+
except CircuitOpenError as exc:
|
|
244
|
+
return policy.give_up(exc, attempt, args, kwargs)
|
|
245
|
+
attempt += 1
|
|
246
|
+
if limiter is not None:
|
|
247
|
+
limiter.acquire()
|
|
248
|
+
try:
|
|
249
|
+
value = _run_with_timeout(fn, args, kwargs, timeout, name)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
_update_breaker(breaker, policy, exc)
|
|
252
|
+
delay = policy.retry_delay(attempt, exc)
|
|
253
|
+
if delay is not None:
|
|
254
|
+
time.sleep(delay)
|
|
255
|
+
continue
|
|
256
|
+
return policy.give_up(exc, attempt, args, kwargs)
|
|
257
|
+
if breaker is not None:
|
|
258
|
+
breaker.record_success()
|
|
259
|
+
return value
|
|
260
|
+
|
|
261
|
+
wrapper = sync_wrapper
|
|
262
|
+
|
|
263
|
+
wrapper.breaker = breaker # type: ignore[attr-defined]
|
|
264
|
+
wrapper.limiter = limiter # type: ignore[attr-defined]
|
|
265
|
+
return wrapper
|
|
266
|
+
|
|
267
|
+
if func is not None:
|
|
268
|
+
return decorate(func)
|
|
269
|
+
return decorate
|
sturdytools/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Exceptions raised by sturdytools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ToolTimeout(TimeoutError):
|
|
7
|
+
"""A tool call ran longer than its time limit."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, tool_name: str, seconds: float):
|
|
10
|
+
self.tool_name = tool_name
|
|
11
|
+
self.seconds = seconds
|
|
12
|
+
super().__init__(f"tool '{tool_name}' took longer than {seconds:g}s")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CircuitOpenError(RuntimeError):
|
|
16
|
+
"""Calls are paused because the tool failed too many times in a row."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, retry_in: float):
|
|
19
|
+
self.retry_in = retry_in
|
|
20
|
+
super().__init__(
|
|
21
|
+
f"calls are paused after repeated failures, next try allowed in {retry_in:.1f}s"
|
|
22
|
+
)
|
sturdytools/ratelimit.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""A thread safe token bucket that works from sync and async code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
_UNITS = {
|
|
11
|
+
"s": 1.0,
|
|
12
|
+
"sec": 1.0,
|
|
13
|
+
"second": 1.0,
|
|
14
|
+
"m": 60.0,
|
|
15
|
+
"min": 60.0,
|
|
16
|
+
"minute": 60.0,
|
|
17
|
+
"h": 3600.0,
|
|
18
|
+
"hr": 3600.0,
|
|
19
|
+
"hour": 3600.0,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RateLimiter:
|
|
24
|
+
"""Allow at most ``rate`` calls every ``per`` seconds.
|
|
25
|
+
|
|
26
|
+
``burst`` is how many calls may go through back to back after a quiet
|
|
27
|
+
period. The default of 1 spaces calls evenly, which is the safest choice
|
|
28
|
+
for APIs with strict limits.
|
|
29
|
+
|
|
30
|
+
One limiter can be shared by several tools that call the same API.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
rate: float,
|
|
36
|
+
per: float = 1.0,
|
|
37
|
+
burst: int = 1,
|
|
38
|
+
clock: Callable[[], float] = time.monotonic,
|
|
39
|
+
):
|
|
40
|
+
if rate <= 0 or per <= 0:
|
|
41
|
+
raise ValueError("rate and per must be positive")
|
|
42
|
+
if burst < 1:
|
|
43
|
+
raise ValueError("burst must be at least 1")
|
|
44
|
+
self.rate = rate
|
|
45
|
+
self.per = per
|
|
46
|
+
self.burst = burst
|
|
47
|
+
self._fill_per_second = rate / per
|
|
48
|
+
self._clock = clock
|
|
49
|
+
self._tokens = float(burst)
|
|
50
|
+
self._last = clock()
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def parse(cls, spec: str, burst: int = 1) -> "RateLimiter":
|
|
55
|
+
"""Build a limiter from text such as ``"5/s"``, ``"60/min"`` or ``"1000/hour"``."""
|
|
56
|
+
try:
|
|
57
|
+
count, unit = spec.replace(" ", "").lower().split("/")
|
|
58
|
+
if unit not in _UNITS and unit.endswith("s"):
|
|
59
|
+
unit = unit[:-1] # "minutes" -> "minute"
|
|
60
|
+
return cls(float(count), per=_UNITS[unit], burst=burst)
|
|
61
|
+
except (ValueError, KeyError):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"could not read rate limit {spec!r}; use a form like '5/s', '60/min' or '1000/hour'"
|
|
64
|
+
) from None
|
|
65
|
+
|
|
66
|
+
def reserve(self) -> float:
|
|
67
|
+
"""Take one slot and return how many seconds the caller must wait for it."""
|
|
68
|
+
with self._lock:
|
|
69
|
+
now = self._clock()
|
|
70
|
+
refill = (now - self._last) * self._fill_per_second
|
|
71
|
+
self._tokens = min(float(self.burst), self._tokens + refill)
|
|
72
|
+
self._last = now
|
|
73
|
+
self._tokens -= 1.0
|
|
74
|
+
if self._tokens >= 0:
|
|
75
|
+
return 0.0
|
|
76
|
+
return -self._tokens / self._fill_per_second
|
|
77
|
+
|
|
78
|
+
def acquire(self) -> None:
|
|
79
|
+
"""Block until a call is allowed."""
|
|
80
|
+
wait = self.reserve()
|
|
81
|
+
if wait > 0:
|
|
82
|
+
time.sleep(wait)
|
|
83
|
+
|
|
84
|
+
async def acquire_async(self) -> None:
|
|
85
|
+
"""Wait, without blocking the event loop, until a call is allowed."""
|
|
86
|
+
wait = self.reserve()
|
|
87
|
+
if wait > 0:
|
|
88
|
+
await asyncio.sleep(wait)
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str:
|
|
91
|
+
return f"RateLimiter(rate={self.rate:g}, per={self.per:g}, burst={self.burst})"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def as_limiter(value: Optional[object]) -> Optional[RateLimiter]:
|
|
95
|
+
if value is None or isinstance(value, RateLimiter):
|
|
96
|
+
return value
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
return RateLimiter.parse(value)
|
|
99
|
+
raise TypeError("rate_limit must be a string like '5/s' or a RateLimiter")
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sturdytools
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Retries, timeouts, rate limits and circuit breakers for AI agent tools
|
|
5
|
+
Author: Meenal Asesh
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ai-with-md/sturdytools
|
|
8
|
+
Project-URL: Issues, https://github.com/ai-with-md/sturdytools/issues
|
|
9
|
+
Keywords: ai agents,llm,tool calling,function calling,mcp,retry,timeout,circuit breaker,rate limit,resilience
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# sturdytools
|
|
23
|
+
|
|
24
|
+
Retries, timeouts, rate limits and circuit breakers for the tools an AI agent calls.
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/sturdytools/)
|
|
27
|
+
[](https://github.com/ai-with-md/sturdytools/actions/workflows/tests.yml)
|
|
28
|
+

|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
An agent is only as reliable as its tools. A search API that drops one request in fifty, a database that hangs, or a service that hits its rate limit can end an agent run, or put a raw stack trace in front of the user. Most of these failures are temporary, and the rest can be handled if the model gets a clear message it can act on.
|
|
32
|
+
|
|
33
|
+
sturdytools is one decorator that handles this for any Python function, sync or async, with no dependencies.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from sturdytools import sturdy
|
|
37
|
+
|
|
38
|
+
@sturdy(timeout=10, retries=3, rate_limit="5/s", on_error="message")
|
|
39
|
+
def search_orders(customer_id: str) -> list:
|
|
40
|
+
"""Find recent orders for a customer."""
|
|
41
|
+
return orders_api.search(customer_id)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If the API drops a connection, the call is retried with backoff. If it hangs, the attempt is abandoned after 10 seconds. If it still fails after 3 retries, the model gets a short message instead of an exception:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
Error: the tool 'search_orders' failed after 4 attempts (ConnectionError: connection reset by peer).
|
|
48
|
+
Check the arguments, try a different approach, or tell the user it is unavailable.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
[](https://github.com/ai-with-md/sturdytools/blob/main/examples/flaky_tools_demo.py)
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install sturdytools
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Python 3.9 or newer. No other packages are needed.
|
|
60
|
+
|
|
61
|
+
## What it does
|
|
62
|
+
|
|
63
|
+
| Option | What it does | Default |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `timeout` | Seconds before one attempt is abandoned with `ToolTimeout` | none |
|
|
66
|
+
| `retries` | Extra attempts after the first one fails | 0 |
|
|
67
|
+
| `retry_on` | Errors that count as temporary. Only these are retried | `Exception` |
|
|
68
|
+
| `backoff`, `max_backoff` | Wait before the first retry, doubling each time with jitter, up to the cap | 0.5s, 8s |
|
|
69
|
+
| `rate_limit` | `"5/s"`, `"60/min"`, `"1000/hour"`, or a shared `RateLimiter` | none |
|
|
70
|
+
| `breaker` | A `CircuitBreaker`, or `True` for one per tool | none |
|
|
71
|
+
| `fallback` | Value to return when the tool gives up. If callable, it gets the tool's arguments | none |
|
|
72
|
+
| `on_error` | `"raise"`, `"message"`, or your own function `(name, error, attempts)` | `"raise"` |
|
|
73
|
+
| `on_retry` | Called as `(attempt, error, delay)` before each retry, for logging or metrics | none |
|
|
74
|
+
|
|
75
|
+
Each call goes through the same steps:
|
|
76
|
+
|
|
77
|
+
1. If the circuit breaker is open, stop right away.
|
|
78
|
+
2. Wait for the rate limiter.
|
|
79
|
+
3. Run the tool with the time limit.
|
|
80
|
+
4. If it raised a temporary error and retries are left, wait and go back to step 1.
|
|
81
|
+
5. Otherwise use the fallback, return a message, or raise, depending on the settings.
|
|
82
|
+
|
|
83
|
+
The wrapper keeps the original function name, docstring and signature, which is what agent frameworks read to build the tool description for the model. Put `@sturdy` closest to the function, under your framework's tool decorator.
|
|
84
|
+
|
|
85
|
+
## Works with MCP servers and LangChain
|
|
86
|
+
|
|
87
|
+
The wrapper keeps the function's name, docstring and signature, so frameworks build the same tool description as before. Put `@sturdy` under the framework's decorator, closest to the function:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from mcp.server.mcpserver import MCPServer
|
|
91
|
+
from sturdytools import sturdy
|
|
92
|
+
|
|
93
|
+
server = MCPServer("weather")
|
|
94
|
+
|
|
95
|
+
@server.tool()
|
|
96
|
+
@sturdy(timeout=10, retries=2, on_error="message")
|
|
97
|
+
async def get_weather(city: str) -> str:
|
|
98
|
+
"""Current weather for a city."""
|
|
99
|
+
...
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from langchain_core.tools import tool
|
|
104
|
+
from sturdytools import sturdy
|
|
105
|
+
|
|
106
|
+
@tool
|
|
107
|
+
@sturdy(timeout=10, retries=2, rate_limit="5/s", on_error="message")
|
|
108
|
+
def search_orders(customer_id: str) -> list:
|
|
109
|
+
"""Find recent orders for a customer."""
|
|
110
|
+
...
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Checked with mcp 2.3 and langchain-core 1.8, for sync and async tools: the tool schema and description are unchanged, retries happen inside the tool call, and a tool that gives up returns the error message to the model as its result.
|
|
114
|
+
|
|
115
|
+
## Sharing limits and breakers across tools
|
|
116
|
+
|
|
117
|
+
Several tools often call the same API. Give them one rate limiter and one breaker, so they respect the same limit and stop together when the service is down.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from sturdytools import CircuitBreaker, RateLimiter, sturdy
|
|
121
|
+
|
|
122
|
+
crm_limit = RateLimiter.parse("10/s")
|
|
123
|
+
crm_breaker = CircuitBreaker(failure_threshold=5, reset_after=30)
|
|
124
|
+
|
|
125
|
+
@sturdy(retries=2, rate_limit=crm_limit, breaker=crm_breaker, on_error="message")
|
|
126
|
+
def get_account(account_id: str) -> dict:
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
@sturdy(retries=2, rate_limit=crm_limit, breaker=crm_breaker, on_error="message")
|
|
130
|
+
def list_contacts(account_id: str) -> list:
|
|
131
|
+
...
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
After 5 temporary failures in a row, both tools stop calling the service for 30 seconds and tell the model it is paused. Then one trial call goes through. If it works, normal calls resume.
|
|
135
|
+
|
|
136
|
+
## Async tools
|
|
137
|
+
|
|
138
|
+
The same decorator works on `async def` functions. Timeouts cancel the task, and the rate limiter and retries wait without blocking the event loop.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
@sturdy(timeout=5, retries=2, fallback=lambda city: "weather unavailable")
|
|
142
|
+
async def get_weather(city: str) -> str:
|
|
143
|
+
async with httpx.AsyncClient() as client:
|
|
144
|
+
response = await client.get(WEATHER_URL, params={"q": city})
|
|
145
|
+
response.raise_for_status()
|
|
146
|
+
return response.text
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Choosing what to retry
|
|
150
|
+
|
|
151
|
+
Retrying everything is rarely right. If the model passed a bad argument, retrying the same call wastes time and money. Limit retries to errors that are actually temporary:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
@sturdy(retries=3, retry_on=(ConnectionError, TimeoutError), on_error="message")
|
|
155
|
+
def lookup_invoice(invoice_id: str) -> dict:
|
|
156
|
+
...
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Here a `KeyError` for an unknown invoice goes straight back to the model as a message, so it can correct itself, and it does not count against the circuit breaker.
|
|
160
|
+
|
|
161
|
+
## Try it
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
python examples/flaky_tools_demo.py
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The [demo](https://github.com/ai-with-md/sturdytools/blob/main/examples/flaky_tools_demo.py) uses a fake weather service that drops connections, hangs, and then goes down, and prints what a plain tool and a wrapped tool do in each case. No API keys are needed.
|
|
168
|
+
|
|
169
|
+
## How it compares
|
|
170
|
+
|
|
171
|
+
- **[tenacity](https://github.com/jd/tenacity)** and **[stamina](https://github.com/hynek/stamina)** are excellent general retry libraries. If retries are all you need, use one of them.
|
|
172
|
+
- sturdytools is narrower and aimed at agent tools: timeouts, retries, rate limits, a circuit breaker and a fallback in one decorator, plus an error message written for the model, so a failing tool becomes something the agent can reason about instead of a crash.
|
|
173
|
+
- Agent frameworks have their own retry options (LangChain's `with_retry`, retry policies on LangGraph nodes). Those retry a whole step. sturdytools works on the single tool function, so it behaves the same in any framework, or in none.
|
|
174
|
+
|
|
175
|
+
## Limits
|
|
176
|
+
|
|
177
|
+
- Python cannot stop a running thread. When a sync tool times out, the caller gets `ToolTimeout` right away, but the original call keeps running in a background thread until it finishes on its own. For tools that can hang for a long time, prefer async, where the task is cancelled, or set a timeout in the client library as well.
|
|
178
|
+
- Rate limits and breakers live in memory, per process. Several workers calling the same API each keep their own count.
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
python -m venv .venv
|
|
184
|
+
source .venv/bin/activate
|
|
185
|
+
pip install -e ".[dev]"
|
|
186
|
+
pytest
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Tests run on Python 3.10, 3.12 and 3.13 on every push.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
sturdytools/__init__.py,sha256=n6vfG-Z5np54pgducgS491nz1vX3dOW7BvAzleKMz84,397
|
|
2
|
+
sturdytools/breaker.py,sha256=511rwpfoNPSnVy0ehMvtoeGId2RNy0_nigx9dZYxhqM,2797
|
|
3
|
+
sturdytools/core.py,sha256=gOo7q1O5QQvSPFYI6OtX_QcqABy2-4GY7WrbhSw1Nqs,10449
|
|
4
|
+
sturdytools/errors.py,sha256=XmshilrjadcaTq9q4y8kdWGK4ZC7UzBb-zh8WqGVHRw,686
|
|
5
|
+
sturdytools/ratelimit.py,sha256=8I77WSIQdzp5mLB--PeRvj5gebbpr7VyuiDKRBgooDQ,3199
|
|
6
|
+
sturdytools-0.1.0.dist-info/licenses/LICENSE,sha256=HS_Pp1J0NNKF4niTKA1MdskkGwByEasM-oJb8NcpaGE,1069
|
|
7
|
+
sturdytools-0.1.0.dist-info/METADATA,sha256=v2wyDhbVFpDVZ14-VI2P4L_nzTarzNH8XRTFbMStdQc,8764
|
|
8
|
+
sturdytools-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
sturdytools-0.1.0.dist-info/top_level.txt,sha256=ey8UcFOvSpYBJq1QI0Bm3d0XAO-iT7V-X3yChhY3YpM,12
|
|
10
|
+
sturdytools-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Meenal Asesh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sturdytools
|