Skip to content

Commit 01b18ac

Browse files
authored
Add examples, PyPI workflow (#4)
* Add examples, PyPI workflow * Better formatting
1 parent 1ca8a3f commit 01b18ac

File tree

3 files changed

+195
-3
lines changed

3 files changed

+195
-3
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Publish Python Package to PyPI
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
permissions:
8+
id-token: write
9+
contents: read
10+
11+
jobs:
12+
build-and-publish:
13+
name: Build and publish Python distributions to PyPI
14+
runs-on: ubuntu-latest
15+
environment:
16+
name: pypi
17+
url: https://pypi.org/p/toller
18+
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@v4
22+
23+
- name: Set up Python
24+
uses: actions/setup-python@v5
25+
with:
26+
python-version: '3.x'
27+
28+
- name: Install build dependencies
29+
run: |
30+
python -m pip install --upgrade pip
31+
pip install build
32+
33+
- name: Build package
34+
run: python -m build
35+
36+
- name: Publish package to PyPI using Trusted Publishing
37+
uses: pypa/gh-action-pypi-publish@release/v1

README.md

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ Toller is a lightweight Python library designed to make your asynchronous calls
88

99
Just as the [Nova Scotia Duck Tolling Retriever](https://www.akc.org/dog-breeds/nova-scotia-duck-tolling-retriever/) lures and guides ducks, Toller "lures" unruly asynchronous tasks into well-managed, predictable flows, guiding the overall execution path and making concurrency easier to reason about.
1010

11-
When deploying applications that rely on numerous external async calls, managing failures (downtime, rate limits, transient errors) in a standard way becomes critical. Toller offers this standard, both for client-side calls and potentially for protecting server-side resources.
11+
## Why Toller?
12+
13+
Modern applications that integrate with numerous LLMs, vector databases, and other microservices, face a constant challenge: external services can be unreliable. They might be temporarily down, enforce rate limits, or return transient errors.
14+
15+
Building robust applications in this environment means every external call needs careful handling, but repeating this logic for every API call leads to boilerplate, inconsistency, and often, poorly managed asynchronous processes. **Toller was built to solve this.** It provides a declarative way to add these resilience patterns.
16+
17+
Toller offers this standard, both for client-side calls and potentially for protecting server-side resources.
1218

1319
## Features
1420

@@ -36,3 +42,153 @@ When deploying applications that rely on numerous external async calls, managing
3642
```bash
3743
pip install toller
3844
```
45+
46+
## Usage and Examples
47+
48+
### Example 1: Basic Resilience for Generative AI Calls
49+
<details open>
50+
For a function that calls out to an LLM, we want to handle rate limits, retry on temporary server issues, and stop if the service is truly down.
51+
52+
```python
53+
import asyncio
54+
import random
55+
from toller import TransientError, FatalError, MaxRetriesExceeded, OpenCircuitError
56+
57+
# Define potential API errors
58+
class LLMRateLimitError(TransientError): pass
59+
class LLMServerError(TransientError): pass
60+
class LLMInputError(FatalError): pass # e.g., prompt too long
61+
62+
# Simulate an LLM call
63+
LLM_DOWN_FOR_DEMO = 0 # Counter for demoing circuit breaker
64+
async def call_llm_api(prompt: str):
65+
global LLM_DOWN_FOR_DEMO
66+
print(f"LLM API: Processing '{prompt[:20]}...' (Attempt for this task)")
67+
await asyncio.sleep(random.uniform(0.1, 0.3)) # Network latency
68+
69+
if LLM_DOWN_FOR_DEMO > 0:
70+
LLM_DOWN_FOR_DEMO -=1
71+
print("LLM API: Simulating 503 Service Unavailable")
72+
raise LLMServerError("LLM service is temporarily down")
73+
if random.random() < 0.2: # 20% chance of a transient rate limit error
74+
print("LLM API: Simulating 429 Rate Limit")
75+
raise LLMRateLimitError("Hit LLM rate limit")
76+
if len(prompt) < 5:
77+
print("LLM API: Simulating 400 Bad Request (prompt too short)")
78+
raise LLMInputError("Prompt is too short")
79+
80+
return f"LLM Response for '{prompt[:20]}...': Generated text."
81+
82+
# Apply Toller
83+
@toller.task(
84+
# Rate Limiter: 60 calls per minute (1 per sec), burst 5
85+
rl_calls_per_second=1.0, # 60 RPM / 60s
86+
rl_max_burst_calls=5,
87+
88+
# Retries: 3 attempts on transient LLM errors
89+
retry_max_attempts=3,
90+
retry_delay=1.0, # Start with 1s delay for LLM errors
91+
retry_backoff=2.0,
92+
retry_on_exception=(LLMRateLimitError, LLMServerError),
93+
retry_stop_on_exception=(LLMInputError,), # Don't retry bad input
94+
95+
# Circuit Breaker: Opens if retries fail 2 times consecutively
96+
cb_failure_threshold=2, # Low threshold for demo
97+
cb_recovery_timeout=20.0, # Wait 20s before one test call
98+
cb_expected_exception=MaxRetriesExceeded # CB trips when all retries are exhausted
99+
)
100+
async def get_llm_completion(prompt: str):
101+
return await call_llm_api(prompt)
102+
103+
async def run_example1():
104+
print("Example 1: Basic Resilience for Generative AI Calls")
105+
prompts = [
106+
"Tell me a story about a brave duck.",
107+
"Explain async programming.",
108+
"Short", # This will cause a FatalError (LLMInputError)
109+
"Another valid prompt after fatal.",
110+
"Prompt to trigger server errors 1", # Will hit retry then MaxRetriesExceeded
111+
"Prompt to trigger server errors 2", # Will hit retry then MaxRetriesExceeded, tripping CB
112+
"Prompt after CB should open", # Should hit OpenCircuitError
113+
]
114+
115+
# Simulated LLM service downtime for the relevant prompts
116+
global LLM_DOWN_FOR_DEMO
117+
LLM_DOWN_FOR_DEMO = 4
118+
119+
for i, p in enumerate(prompts):
120+
print(f"\Sending request: '{p}'")
121+
try:
122+
result = await get_llm_completion(p)
123+
print(f"Success: {result}")
124+
except MaxRetriesExceeded as e:
125+
print(f"Toller: Max retries exceeded. Last error: {type(e.last_exception).__name__}: {e.last_exception}")
126+
except OpenCircuitError as e:
127+
print(f"Toller: Circuit is open! {e}. Further calls blocked temporarily.")
128+
if i == len(prompts) - 2: # If this is the call just before the last one
129+
print("Waiting for circuit breaker recovery timeout for demo...")
130+
await asyncio.sleep(21) # Wait for CB to go HALF_OPEN
131+
except FatalError as e:
132+
print(f"Toller: Fatal error, no retries. Error: {type(e).__name__}: {e}")
133+
except Exception as e:
134+
print(f"Toller: Unexpected error. Type: {type(e).__name__}, Error: {e}")
135+
136+
await asyncio.sleep(0.3) # Small pause between top-level requests to see rate limiter too
137+
138+
if __name__ == "__main__":
139+
asyncio.run(run_example1())
140+
```
141+
</details>
142+
143+
144+
### Example 2: Shared Rate Limiter for Multiple Related API Calls
145+
<details>
146+
Often, different API endpoints for the same service share an overall rate limit.
147+
148+
```python
149+
import time
150+
from toller import CallRateLimiter # For creating a shared instance
151+
152+
# Assume these two functions call endpoints that share a single rate limit pool
153+
shared_api_rl = CallRateLimiter(calls_per_second=2, max_burst_calls=2, name="MyServiceSharedRL")
154+
155+
@toller.task(
156+
rate_limiter_instance=shared_api_rl,
157+
# Disable retry/CB for this simple RL demo
158+
enable_retry=False, enable_circuit_breaker=False
159+
)
160+
async def call_endpoint_a(item_id: int):
161+
print(f"Calling A for {item_id}...")
162+
await asyncio.sleep(0.1)
163+
return f"A {item_id} done"
164+
165+
@toller.task(
166+
rate_limiter_instance=shared_api_rl,
167+
enable_retry=False, enable_circuit_breaker=False
168+
)
169+
async def call_endpoint_b(item_id: int):
170+
print(f"Calling B for {item_id}...")
171+
await asyncio.sleep(0.1)
172+
return f"B {item_id} done"
173+
174+
async def run_example2():
175+
print("\nExample 2: Shared Rate Limiter")
176+
tasks = []
177+
# These 4 calls will exceed the burst of 2 for the shared limiter (rate 2/sec), so, some will be delayed.
178+
tasks.append(call_endpoint_a(1))
179+
tasks.append(call_endpoint_b(1))
180+
tasks.append(call_endpoint_a(2))
181+
tasks.append(call_endpoint_b(2))
182+
183+
start_time = time.time()
184+
results = await asyncio.gather(*tasks)
185+
duration = time.time() - start_time
186+
187+
for res in results:
188+
print(f"Shared RL Result: {res}")
189+
print(f"Total time for 4 calls with shared RL (2/sec, burst 2): {duration:.2f}s (expected > ~1.0s)")
190+
191+
if __name__ == "__main__":
192+
asyncio.run(run_example2())
193+
```
194+
</details>

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "toller"
7-
version = "0.0.1"
7+
version = "0.0.2"
88
description = "Intelligent async flow controller for Python - making complex asyncio workflows manageable"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -26,7 +26,6 @@ classifiers = [
2626
"Intended Audience :: Developers",
2727
"License :: OSI Approved :: MIT License",
2828
"Programming Language :: Python :: 3",
29-
"Programming Language :: Python :: 3.9",
3029
"Programming Language :: Python :: 3.10",
3130
"Programming Language :: Python :: 3.11",
3231
"Programming Language :: Python :: 3.12",

0 commit comments

Comments
 (0)