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.
@@ -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
+ )
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/sturdytools)](https://pypi.org/project/sturdytools/)
27
+ [![tests](https://github.com/ai-with-md/sturdytools/actions/workflows/tests.yml/badge.svg)](https://github.com/ai-with-md/sturdytools/actions/workflows/tests.yml)
28
+ ![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue)
29
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
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
+ [![Demo: a plain tool crashes, the wrapped tool retries, times out, and stops calling a service that is down](https://raw.githubusercontent.com/ai-with-md/sturdytools/main/docs/demo.png)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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