sturdytools 0.1.0__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.
- sturdytools-0.1.0/LICENSE +21 -0
- sturdytools-0.1.0/PKG-INFO +193 -0
- sturdytools-0.1.0/README.md +172 -0
- sturdytools-0.1.0/pyproject.toml +34 -0
- sturdytools-0.1.0/setup.cfg +4 -0
- sturdytools-0.1.0/src/sturdytools/__init__.py +17 -0
- sturdytools-0.1.0/src/sturdytools/breaker.py +88 -0
- sturdytools-0.1.0/src/sturdytools/core.py +269 -0
- sturdytools-0.1.0/src/sturdytools/errors.py +22 -0
- sturdytools-0.1.0/src/sturdytools/ratelimit.py +99 -0
- sturdytools-0.1.0/src/sturdytools.egg-info/PKG-INFO +193 -0
- sturdytools-0.1.0/src/sturdytools.egg-info/SOURCES.txt +15 -0
- sturdytools-0.1.0/src/sturdytools.egg-info/dependency_links.txt +1 -0
- sturdytools-0.1.0/src/sturdytools.egg-info/requires.txt +3 -0
- sturdytools-0.1.0/src/sturdytools.egg-info/top_level.txt +1 -0
- sturdytools-0.1.0/tests/test_parts.py +124 -0
- sturdytools-0.1.0/tests/test_sturdy.py +302 -0
|
@@ -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,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,172 @@
|
|
|
1
|
+
# sturdytools
|
|
2
|
+
|
|
3
|
+
Retries, timeouts, rate limits and circuit breakers for the tools an AI agent calls.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/sturdytools/)
|
|
6
|
+
[](https://github.com/ai-with-md/sturdytools/actions/workflows/tests.yml)
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
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.
|
|
11
|
+
|
|
12
|
+
sturdytools is one decorator that handles this for any Python function, sync or async, with no dependencies.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from sturdytools import sturdy
|
|
16
|
+
|
|
17
|
+
@sturdy(timeout=10, retries=3, rate_limit="5/s", on_error="message")
|
|
18
|
+
def search_orders(customer_id: str) -> list:
|
|
19
|
+
"""Find recent orders for a customer."""
|
|
20
|
+
return orders_api.search(customer_id)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
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:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Error: the tool 'search_orders' failed after 4 attempts (ConnectionError: connection reset by peer).
|
|
27
|
+
Check the arguments, try a different approach, or tell the user it is unavailable.
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
[](https://github.com/ai-with-md/sturdytools/blob/main/examples/flaky_tools_demo.py)
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install sturdytools
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Python 3.9 or newer. No other packages are needed.
|
|
39
|
+
|
|
40
|
+
## What it does
|
|
41
|
+
|
|
42
|
+
| Option | What it does | Default |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `timeout` | Seconds before one attempt is abandoned with `ToolTimeout` | none |
|
|
45
|
+
| `retries` | Extra attempts after the first one fails | 0 |
|
|
46
|
+
| `retry_on` | Errors that count as temporary. Only these are retried | `Exception` |
|
|
47
|
+
| `backoff`, `max_backoff` | Wait before the first retry, doubling each time with jitter, up to the cap | 0.5s, 8s |
|
|
48
|
+
| `rate_limit` | `"5/s"`, `"60/min"`, `"1000/hour"`, or a shared `RateLimiter` | none |
|
|
49
|
+
| `breaker` | A `CircuitBreaker`, or `True` for one per tool | none |
|
|
50
|
+
| `fallback` | Value to return when the tool gives up. If callable, it gets the tool's arguments | none |
|
|
51
|
+
| `on_error` | `"raise"`, `"message"`, or your own function `(name, error, attempts)` | `"raise"` |
|
|
52
|
+
| `on_retry` | Called as `(attempt, error, delay)` before each retry, for logging or metrics | none |
|
|
53
|
+
|
|
54
|
+
Each call goes through the same steps:
|
|
55
|
+
|
|
56
|
+
1. If the circuit breaker is open, stop right away.
|
|
57
|
+
2. Wait for the rate limiter.
|
|
58
|
+
3. Run the tool with the time limit.
|
|
59
|
+
4. If it raised a temporary error and retries are left, wait and go back to step 1.
|
|
60
|
+
5. Otherwise use the fallback, return a message, or raise, depending on the settings.
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
## Works with MCP servers and LangChain
|
|
65
|
+
|
|
66
|
+
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:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from mcp.server.mcpserver import MCPServer
|
|
70
|
+
from sturdytools import sturdy
|
|
71
|
+
|
|
72
|
+
server = MCPServer("weather")
|
|
73
|
+
|
|
74
|
+
@server.tool()
|
|
75
|
+
@sturdy(timeout=10, retries=2, on_error="message")
|
|
76
|
+
async def get_weather(city: str) -> str:
|
|
77
|
+
"""Current weather for a city."""
|
|
78
|
+
...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from langchain_core.tools import tool
|
|
83
|
+
from sturdytools import sturdy
|
|
84
|
+
|
|
85
|
+
@tool
|
|
86
|
+
@sturdy(timeout=10, retries=2, rate_limit="5/s", on_error="message")
|
|
87
|
+
def search_orders(customer_id: str) -> list:
|
|
88
|
+
"""Find recent orders for a customer."""
|
|
89
|
+
...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
## Sharing limits and breakers across tools
|
|
95
|
+
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from sturdytools import CircuitBreaker, RateLimiter, sturdy
|
|
100
|
+
|
|
101
|
+
crm_limit = RateLimiter.parse("10/s")
|
|
102
|
+
crm_breaker = CircuitBreaker(failure_threshold=5, reset_after=30)
|
|
103
|
+
|
|
104
|
+
@sturdy(retries=2, rate_limit=crm_limit, breaker=crm_breaker, on_error="message")
|
|
105
|
+
def get_account(account_id: str) -> dict:
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
@sturdy(retries=2, rate_limit=crm_limit, breaker=crm_breaker, on_error="message")
|
|
109
|
+
def list_contacts(account_id: str) -> list:
|
|
110
|
+
...
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
## Async tools
|
|
116
|
+
|
|
117
|
+
The same decorator works on `async def` functions. Timeouts cancel the task, and the rate limiter and retries wait without blocking the event loop.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
@sturdy(timeout=5, retries=2, fallback=lambda city: "weather unavailable")
|
|
121
|
+
async def get_weather(city: str) -> str:
|
|
122
|
+
async with httpx.AsyncClient() as client:
|
|
123
|
+
response = await client.get(WEATHER_URL, params={"q": city})
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
return response.text
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Choosing what to retry
|
|
129
|
+
|
|
130
|
+
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:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
@sturdy(retries=3, retry_on=(ConnectionError, TimeoutError), on_error="message")
|
|
134
|
+
def lookup_invoice(invoice_id: str) -> dict:
|
|
135
|
+
...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
## Try it
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
python examples/flaky_tools_demo.py
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
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.
|
|
147
|
+
|
|
148
|
+
## How it compares
|
|
149
|
+
|
|
150
|
+
- **[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.
|
|
151
|
+
- 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.
|
|
152
|
+
- 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.
|
|
153
|
+
|
|
154
|
+
## Limits
|
|
155
|
+
|
|
156
|
+
- 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.
|
|
157
|
+
- Rate limits and breakers live in memory, per process. Several workers calling the same API each keep their own count.
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
python -m venv .venv
|
|
163
|
+
source .venv/bin/activate
|
|
164
|
+
pip install -e ".[dev]"
|
|
165
|
+
pytest
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Tests run on Python 3.10, 3.12 and 3.13 on every push.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sturdytools"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Retries, timeouts, rate limits and circuit breakers for AI agent tools"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{name = "Meenal Asesh"}]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["ai agents", "llm", "tool calling", "function calling", "mcp", "retry", "timeout", "circuit breaker", "rate limit", "resilience"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Framework :: AsyncIO",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/ai-with-md/sturdytools"
|
|
28
|
+
Issues = "https://github.com/ai-with-md/sturdytools/issues"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
@@ -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"
|
|
@@ -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
|
+
)
|